Skip to content

Commit 7e5c9b4

Browse files
committed
improvement(redis): warm the signal subscriber on intent, not at worker start
Only the tasks that execute a workflow ever subscribe to cancellation signals, and they are a minority of runs: over a week, 71% of Trigger.dev runs never subscribe, and a single document-processing task is 61% on its own. Warming the subscriber in the global init hook opened a TLS connection on every one of those runs that nothing would use. Warm it instead at `executeWorkflowCore` — the one path every execution shares — fire-and-forget as the first statement, so the handshake overlaps the custom-block read and preprocessing ahead of the cancellation subscribe rather than being paid inside that subscribe's readiness budget. No task-id list to keep in sync: coverage follows from the funnel. The subscribe and the warm-up share the hub's memoized readiness wait, so their budgets never compound within a run. The global init keeps warming only the shared client, which nearly every task uses; the Next server keeps warming both at boot, one connection per long-lived process.
1 parent e2063b5 commit 7e5c9b4

4 files changed

Lines changed: 51 additions & 16 deletions

File tree

apps/sim/lib/execution/execution-signal.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -306,10 +306,13 @@ export function getExecutionSignalHub(): ExecutionSignalHub {
306306
}
307307

308308
/**
309-
* Establishes the hub's subscriber connection ahead of the first execution, so
310-
* a run's cancellation subscription does not pay the handshake inside its own
311-
* readiness budget. Never throws: this runs from process start-up hooks where
312-
* a throw would fail the run, and a cold hub is only slower, not wrong.
309+
* Establishes the hub's subscriber connection ahead of a cancellation
310+
* subscription, so that subscribe does not pay the handshake inside its own
311+
* readiness budget. Called fire-and-forget at the execution entry point — the
312+
* one path every execution shares, early enough to overlap the work ahead of
313+
* the subscribe — and at boot in long-lived servers. Never throws or rejects:
314+
* a throw from a start-up hook would fail the run, and a cold hub is only
315+
* slower, not wrong.
313316
*/
314317
export async function warmExecutionSignalHub(): Promise<boolean> {
315318
let hub: ExecutionSignalHub

apps/sim/lib/workflows/executor/execution-core.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ vi.mock('@/lib/execution/cancellation', () => ({
9797
clearExecutionCancellation: clearExecutionCancellationMock,
9898
}))
9999

100+
const { warmExecutionSignalHubMock } = vi.hoisted(() => ({
101+
warmExecutionSignalHubMock: vi.fn(),
102+
}))
103+
104+
vi.mock('@/lib/execution/execution-signal', () => ({
105+
warmExecutionSignalHub: warmExecutionSignalHubMock,
106+
}))
107+
100108
vi.mock('@/lib/core/security/encryption', () => ({
101109
decryptSecret: decryptSecretMock,
102110
}))
@@ -376,6 +384,23 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
376384
expect(executorConstructorMock).toHaveBeenCalledTimes(1)
377385
})
378386

387+
it('begins warming the signal subscriber synchronously, before the first await', async () => {
388+
warmExecutionSignalHubMock.mockResolvedValue(true)
389+
390+
const executionPromise = executeWorkflowCore({
391+
snapshot: createSnapshot() as any,
392+
callbacks: {},
393+
loggingSession: loggingSession as any,
394+
})
395+
396+
// Asserted with no await in between: the handshake has to start ahead of
397+
// the custom-block read, or it stops overlapping the work that precedes the
398+
// cancellation subscribe and is paid inside that subscribe's budget instead.
399+
expect(warmExecutionSignalHubMock).toHaveBeenCalledOnce()
400+
401+
await executionPromise
402+
})
403+
379404
it('routes onBlockStart through logging session persistence path', async () => {
380405
executorExecuteMock.mockResolvedValue({
381406
success: true,

apps/sim/lib/workflows/executor/execution-core.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { withDatabaseReadRetry } from '@/lib/db/read-retry'
2323
import { getExecutionEnvironment } from '@/lib/environment/utils'
2424
import { clearExecutionCancellation } from '@/lib/execution/cancellation'
25+
import { warmExecutionSignalHub } from '@/lib/execution/execution-signal'
2526
import { warmLargeValueRefs } from '@/lib/execution/payloads/hydration'
2627
import { parseLargeExecutionValue } from '@/lib/execution/payloads/large-execution-value'
2728
import type { LoggingSession } from '@/lib/logs/execution/logging-session'
@@ -381,6 +382,14 @@ async function finalizeExecutionError(params: {
381382
export async function executeWorkflowCore(
382383
options: ExecuteWorkflowCoreOptions
383384
): Promise<ExecutionResult> {
385+
// First, and not awaited: every execution subscribes to cancellation signals
386+
// once its engine starts, so the subscriber's handshake is begun here — the
387+
// one path all of them share — and overlaps the reads and preprocessing
388+
// ahead of that subscribe instead of being paid inside its readiness budget.
389+
// Warming on intent rather than at worker start keeps the tasks that never
390+
// execute a workflow, most of the fleet by volume, from opening a connection
391+
// they would never use.
392+
void warmExecutionSignalHub()
384393
const workspaceId = options.snapshot.metadata.workspaceId
385394
const rows = workspaceId
386395
? await withDatabaseReadRetry(() => getCustomBlockRowsForWorkspace(workspaceId), {

apps/sim/trigger.config.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,24 +102,22 @@ export default defineConfig({
102102
* environment variables whether Trigger.dev is available: a process that
103103
* Trigger.dev is executing has Trigger.dev available by definition.
104104
*
105-
* Also warms both Redis connections a run opens — the shared client, whose
106-
* first call is typically a lock acquire, and the execution-signal subscriber,
107-
* whose first call is the cancellation subscription — because each would
108-
* otherwise pay its handshake inside its own deadline. Warmed in parallel so
109-
* the run waits for the slower of the two, not the sum; awaited so both are up
105+
* Also warms the shared Redis connection, because nearly every task's first
106+
* Redis call — a lock acquire, a usage reservation — would otherwise pay the
107+
* handshake inside its own command deadline. Awaited so the connection is up
110108
* before `run()` issues anything; imported dynamically so deploy-time
111-
* evaluation of this config does not pull the clients; and neither ever
112-
* throws, because a throw here fails the run.
109+
* evaluation of this config does not pull the client; and never throwing,
110+
* because a throw here fails the run. The execution-signal subscriber is
111+
* deliberately not warmed here: only the tasks that execute a workflow ever
112+
* subscribe, and they are a minority of runs, so that connection is warmed
113+
* on intent at the execution entry point instead.
113114
*
114115
* @see https://trigger.dev/docs/config/config-file#lifecycle-functions
115116
*/
116117
init: async () => {
117118
markInsideTriggerRun()
118-
const [{ warmRedisConnection }, { warmExecutionSignalHub }] = await Promise.all([
119-
import('./lib/core/config/redis'),
120-
import('./lib/execution/execution-signal'),
121-
])
122-
await Promise.all([warmRedisConnection(), warmExecutionSignalHub()])
119+
const { warmRedisConnection } = await import('./lib/core/config/redis')
120+
await warmRedisConnection()
123121
},
124122
...(grafanaTelemetry ? { telemetry: grafanaTelemetry } : {}),
125123
build: {

0 commit comments

Comments
 (0)