Skip to content

testing: add with_deferred_starts to model async start_workflow - #3

Draft
jelinson-figma wants to merge 2 commits into
figma:masterfrom
jelinson-figma:jelinson/testing-deferred-starts
Draft

testing: add with_deferred_starts to model async start_workflow#3
jelinson-figma wants to merge 2 commits into
figma:masterfrom
jelinson-figma:jelinson/testing-deferred-starts

Conversation

@jelinson-figma

@jelinson-figma jelinson-figma commented Jun 29, 2026

Copy link
Copy Markdown

Summary

In local testing mode (Temporal::Testing.local!), start_workflow runs the workflow inline and synchronously, collapsing the start/run split that a real Temporal server has. A caller that starts a fire-and-forget workflow while holding a resource the workflow re-acquires (e.g. a lock) then deadlocks -- the workflow runs inline mid-call instead of after the caller releases.

  • Temporal::Testing.with_deferred_starts { ... } queues fire-and-forget start_workflow calls made inside the block and runs them when the block exits, after the caller's stack has unwound -- matching the async ordering of a real worker.
  • Mirrors the existing cron deferral (schedule_workflow -> ScheduledWorkflows), reusing the same executor-lambda + drain pattern.
  • The drain runs each queued workflow independently -- a failure is recorded as FAILED (not propagated), so one failing workflow does not abort the others, mirroring separate executions on a real worker.

NOTE: awaited workflows can't be deferred -- await_workflow_result needs the result synchronously and local mode does not capture a workflow's return value. Only fire-and-forget starts belong inside the block.

NOTE: the block requires local! mode and cannot be nested -- the queue is a single flat array, so a nested block would drain/clear the outer's queued starts. Both are guarded with a raise rather than failing silently.

NOTE: style follows the existing ScheduledWorkflows module -- a public API delegating to a Private::Store defined with class << self -- rather than introducing a new convention.

Example

# Before -- start_workflow runs the workflow inline, mid-call, so its activity
# deadlocks re-acquiring a lock the caller still holds. The test had to stub the
# activity to dodge the real lock acquire:
Spy.on(MyWorkflow::SomeActivity, :execute!).and_return(stubbed_result)
RunMyWorkflow  # starts MyWorkflow inline, while reconcile still holds the lock

# After -- defer the start so the workflow runs once caller context returns and
# releases the lock, the ordering a real worker sees. No stub needed:
Temporal::Testing.with_deferred_starts { RunMyWorkflow }

Tests

  • rspec spec/unit/lib/temporal/testing/temporal_override_spec.rb -- 30 examples, 0 failures. New cases: defers until block exit, runs inline outside the block, does not run deferred workflows when the block raises, restores inline execution after the block, runs every deferred workflow even if an earlier one fails, raises when nested, raises when not in local mode.

jelinson-figma and others added 2 commits June 29, 2026 17:04
In local testing mode start_workflow runs the workflow inline and
synchronously, which collapses the start/run split of a real Temporal
server. That deadlocks a caller which starts a fire-and-forget workflow
while holding a resource (e.g. a lock) that the workflow then re-acquires:
the workflow runs inline mid-call instead of after the caller releases.

with_deferred_starts queues fire-and-forget start_workflow calls and runs
them when the block exits, after the caller's stack has unwound -- matching
the async ordering of a real worker. Awaited workflows can't be deferred
(await needs the result synchronously), so only fire-and-forget starts
belong inside the block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ndence

Address review:
- raise unless local! and raise on nested calls; the flat queue can't support
  nesting, so forbid it rather than silently drop workflows.
- clear the defer flag (not restore-to-previous) before draining.
- document/verify that the drain runs each workflow independently -- one failing
  is recorded FAILED and does not abort the others.

Adds specs for nesting, non-local, and drain-after-failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant