Skip to content

A sleep() that loses a Promise.race can never be closed, and one abandoned timer disables retention and inline deltas for the rest of the run #3916

Description

@shtefcs

Summary

There is no way to dispose a wait. Hooks have hook_created, hook_received and hook_disposed, but waits only have wait_created and wait_completed. So if a workflow races a hook against a sleep as a timeout, and the hook wins, the losing sleep stays open in the journal until its timer elapses. There is nothing the workflow can do about it.

That would just be untidy, except open waits also gate the fast paths. In beta.42, canRetainWorkflowSession and the inline delta path both check for open hooks and waits, and in beta.46 getRetentionDecision still returns false for any open wait (runtime.js, case 'wait'). The turbo optimistic start also never returns once any wait has appeared in the log, since it requires attempt 1 with no incoming step.

What we measured (production, managed world, iad1, core beta.42)

Our human approval gate was written as the docs suggest, hook based, but with a 24 hour timeout added:

const outcome = await Promise.race([
  hook,
  sleep("24h").then(() => ({ kind: "timeout" })),
]);

The user approved 89 seconds into a run. The hook resolved in 13 seconds. The abandoned sleep stayed open for the remaining 62 minutes of the run, and:

  • step boundary re-entry went from 218ms median (43 steps before the gate) to 4,382ms after, a 20x regression that ramped with journal growth and never recovered
  • roughly 19 percent of the total wall clock of that run went to re-entry alone

The pre-gate segment is a clean control: same code, same VM, same run.

Workaround that fixed it for us

We removed the in-run timeout entirely. The gate now parks on the hook alone, and a cron job resumes the hook with a timeout payload if nobody answers within 24 hours. After that change (plus beta.46 so open hooks retain), step boundaries stay at about 108ms for entire runs, including after approvals.

Ask

Either of these would solve it properly:

  1. A way to dispose an abandoned wait, for example sleep returning a handle with a dispose method, or the race pattern being detected.
  2. Retention and the inline delta path ignoring waits whose resolution can no longer affect control flow.

Versions: workflow 5.0.0-beta.42 and 5.0.0-beta.46, @ai-sdk/workflow 1.0.30, Vercel managed world. Full timing methodology available if useful, it all comes from workflow inspect events plus computing step_created minus previous step_completed per boundary.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions