Skip to content

Commit 3535caf

Browse files
[core] Skip inline step execution when suspension also has a wait (vercel#1924)
1 parent 540a2ef commit 3535caf

5 files changed

Lines changed: 78 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/core": patch
3+
---
4+
5+
Fix `Promise.race(step, sleep)` always blocking until step completed

docs/content/docs/changelog/eager-processing.mdx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,15 @@ When an inline step fails with retries remaining:
230230

231231
### Mixed Suspensions
232232

233-
A suspension may contain steps, hooks, and waits simultaneously. The handler creates events for all, executes any pending step inline, and returns with the wait timeout if applicable. The workflow will re-suspend on next replay for the still-pending hooks/waits.
233+
A suspension may contain steps, hooks, and waits simultaneously. The handler creates events for all, then chooses between inline execution and queue dispatch:
234+
235+
- **Steps only** (no waits): one owned step is executed inline; the rest are queued. The loop continues after the inline step completes.
236+
- **Steps + at least one wait**: every step is queued (no inline execution). The handler returns with the wait timeout. Whichever lands first — a step's continuation or the wait timer — drives the next replay.
237+
- **Hooks / waits only**: handler returns with the wait timeout (or no timeout, for hook-only suspensions). The next continuation is driven by external resume or the wait timer.
238+
239+
The "no inline when there's a wait" carve-out is necessary to preserve `Promise.race(step, sleep)` semantics. Inline `await executeStep(...)` blocks the handler for the full step duration, and `wait_completed` events are only created on the *next* loop iteration's "complete elapsed waits" pass — so a longer-running step would always swallow the shorter sleep and `Promise.race` would resolve incorrectly. Queueing the step in this case lets the wait timer drive a continuation in parallel, matching V1's behavior where each step ran in a separate function invocation.
240+
241+
Pure step suspensions (without waits) still benefit from inline execution; the carve-out only costs an extra queue roundtrip when a step and a sleep coexist.
234242

235243
### Hook Conflicts
236244

packages/core/e2e/e2e.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,22 @@ describe('e2e', () => {
581581
expect(elapsed).toBeLessThan(25_000);
582582
});
583583

584+
test('sleepWinsRaceWorkflow', { timeout: 60_000 }, async () => {
585+
const run = await start(await e2e('sleepWinsRaceWorkflow'), []);
586+
const returnValue = await run.returnValue;
587+
expect(returnValue.winner).toBe('sleep');
588+
// Sleep is 1s; step would take 10s. Should resolve in ~1s, well under 5s.
589+
expect(returnValue.durationMs).toBeLessThan(5_000);
590+
});
591+
592+
test('stepWinsRaceWorkflow', { timeout: 60_000 }, async () => {
593+
const run = await start(await e2e('stepWinsRaceWorkflow'), []);
594+
const returnValue = await run.returnValue;
595+
expect(returnValue.winner).toBe('step');
596+
// Step is 1s; sleep would take 10s. Should resolve in ~1s, well under 5s.
597+
expect(returnValue.durationMs).toBeLessThan(5_000);
598+
});
599+
584600
test('nullByteWorkflow', { timeout: 60_000 }, async () => {
585601
const run = await start(await e2e('nullByteWorkflow'), []);
586602
const returnValue = await run.returnValue;

packages/core/src/runtime.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -882,9 +882,26 @@ export function workflowEntrypoint(
882882

883883
// Pick one owned step to execute inline (if any).
884884
// The rest of the pending steps are queued below.
885+
//
886+
// Skip inline execution entirely when the suspension
887+
// also has a pending wait (sleep): an inline `await
888+
// executeStep(...)` blocks the handler for the full
889+
// step duration, so the wait timer never has a chance
890+
// to fire on time. That defeats `Promise.race(step,
891+
// sleep)` semantics — if the sleep is shorter than
892+
// the step, replay still picks the step because
893+
// wait_completed is only created on the *next* loop
894+
// iteration, which doesn't run until the step
895+
// finishes. Queueing every step in this case lets
896+
// the wait timeout drive a continuation in parallel,
897+
// matching V1's behavior where each step ran in a
898+
// separate function invocation.
885899
const inlineStep:
886900
| (typeof pendingSteps)[number]
887-
| undefined = ownedPendingSteps[0];
901+
| undefined =
902+
suspensionResult.timeoutSeconds === undefined
903+
? ownedPendingSteps[0]
904+
: undefined;
888905

889906
// Queue every pending step except the one we're
890907
// executing inline. This mirrors V1's unconditional

workbench/example/workflows/99_e2e.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,36 @@ export async function parallelSleepWorkflow() {
215215

216216
//////////////////////////////////////////////////////////
217217

218+
async function delayMsStep(ms: number, label: string) {
219+
'use step';
220+
await new Promise((resolve) => setTimeout(resolve, ms));
221+
return label;
222+
}
223+
224+
export async function sleepWinsRaceWorkflow() {
225+
'use workflow';
226+
const startTime = Date.now();
227+
const winner = await Promise.race([
228+
delayMsStep(10_000, 'step'),
229+
sleep('1s').then(() => 'sleep'),
230+
]);
231+
const endTime = Date.now();
232+
return { winner, durationMs: endTime - startTime };
233+
}
234+
235+
export async function stepWinsRaceWorkflow() {
236+
'use workflow';
237+
const startTime = Date.now();
238+
const winner = await Promise.race([
239+
delayMsStep(1_000, 'step'),
240+
sleep('10s').then(() => 'sleep'),
241+
]);
242+
const endTime = Date.now();
243+
return { winner, durationMs: endTime - startTime };
244+
}
245+
246+
//////////////////////////////////////////////////////////
247+
218248
async function nullByteStep() {
219249
'use step';
220250
return 'null byte \0';

0 commit comments

Comments
 (0)