Skip to content

Fix DurableDeferred race wake-up - #7179

Open
tim-smart wants to merge 10 commits into
mainfrom
agent/fix-durable-deferred-race-wakeup
Open

Fix DurableDeferred race wake-up#7179
tim-smart wants to merge 10 commits into
mainfrom
agent/fix-durable-deferred-race-wakeup

Conversation

@tim-smart

@tim-smart tim-smart commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • give DurableDeferred.raceAll a first-class in-process wake instead of interrupt-and-replay
  • a completed deferred re-runs the branch that was parked on it; the branch's own pipeline produces the race winner, and the workflow run is never interrupted or replayed
  • preserve Effect.raceAll's success-biased failure semantics for durable races
  • restore the memory-engine live wake with the same mechanism
  • a durable clock or signal awaited anywhere inside a racing branch (directly, transformed, into-wrapped, or inside an activity body) now wins the race live

Root cause

A pending DurableDeferred.await suspends its own race branch, so the workflow stays active while another branch can still succeed. The engine's only wake primitive was suspend-and-replay, so a persisted completion had nobody to deliver to and the activity became the eventual winner.

Design

Branches keep their normal semantics: awaiting a pending deferred (or a suspended activity) interrupts the branch, exactly as before. The race gains one extra arm, a wake arm, that parks on a latch and is the single place race outcomes beyond a normal branch win are decided:

  • Each branch gets its own registration context. An await on a pending deferred registers it (flagged direct) before suspending; nested races bubble their registrations to the enclosing branch (flagged bubbled). The context reaches activity bodies too, since engines capture the calling context for activity execution.
  • When a deferred completes, the engine records the result and opens the execution's wake latches. The wake arm re-runs the branch that registered the completed deferred, so the branch's own pipeline produces the result — branch bodies are replay-safe by construction, and a directly-parked branch can never produce a value itself, so the re-run is safe even while the original unwinds. Bubbled registrations defer to the live nested race and are only taken over once that branch is dead.
  • When every branch has suspended, the wake arm converts that into a durable suspension of the workflow. It only commits when nothing raced with its polling (the latch would have reopened), so a completion can never be lost to a concurrent suspension: the completer observes the committed suspension and resumes the execution after it settles.
  • If a branch fails for real, the wake arm steps aside so the race settles with the aggregated failures, preserving success bias.

This deletes the waiter registry, the interrupt-and-replay path, and the waiter lifecycle cleanup from the previous iterations. The per-execution pending-result retention and the entity's concurrency: 2 remain, serving the read fast-path and the wake delivery.

Test coverage

Mutation-verified guards (each mutation kills exactly its test):

  • deferred wins in place without replaying the run (runs === 1)
  • into-wrapped branch wakes
  • transformed await keeps its transformation on a wake (signal!)
  • bare DurableClock.sleep branch wins with its own value
  • durable clock inside an activity body wins live via the activity reset path
  • all-branches-pending race suspends durably and resumes with the right winner
  • completion landing while a suspension commits is delivered after the settle (WAIT-SUSPENDED path)
  • memory-engine live wake (fails deterministically if the wake block is removed)

Validation

  • pnpm vitest run packages/effect/test (8106 passed)
  • cluster + workflow suites 88/88, stable across repeat runs
  • deno task test run packages/effect/test/unstable/workflow/WorkflowEngine.test.ts (5 passed)
  • pnpm lint
  • pnpm check

Closes EFF-588
Closes #7176

@changeset-bot

changeset-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1003c98

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 30 packages
Name Type
effect Patch
@effect/ai-anthropic Patch
@effect/ai-openai Patch
@effect/ai-openai-compat Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node Patch
@effect/platform-node-shared Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/vitest Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@effect-slopcop effect-slopcop Bot added 4.0 bug Something isn't working labels Aug 11, 2026
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
basic.ts 6.91 KB 6.91 KB 0.00 KB (0.00%)
batching.ts 9.72 KB 9.72 KB 0.00 KB (0.00%)
brand.ts 6.50 KB 6.50 KB 0.00 KB (0.00%)
cache.ts 10.63 KB 10.63 KB 0.00 KB (0.00%)
config.ts 21.05 KB 21.05 KB 0.00 KB (0.00%)
differ.ts 19.99 KB 19.99 KB 0.00 KB (0.00%)
http-client.ts 21.55 KB 21.55 KB 0.00 KB (0.00%)
logger.ts 10.88 KB 10.88 KB 0.00 KB (0.00%)
metric.ts 8.86 KB 8.86 KB 0.00 KB (0.00%)
optic.ts 6.67 KB 6.67 KB 0.00 KB (0.00%)
pubsub.ts 14.90 KB 14.90 KB 0.00 KB (0.00%)
queue.ts 11.57 KB 11.57 KB 0.00 KB (0.00%)
schedule.ts 10.74 KB 10.74 KB 0.00 KB (0.00%)
schema-class.ts 19.61 KB 19.61 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 29.57 KB 29.57 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 25.81 KB 25.81 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.49 KB 13.49 KB 0.00 KB (0.00%)
schema-string.ts 10.99 KB 10.99 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.30 KB 15.30 KB 0.00 KB (0.00%)
schema-toArbitrary.ts 21.73 KB 21.73 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.20 KB 24.20 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 18.96 KB 18.96 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 18.78 KB 18.78 KB 0.00 KB (0.00%)
schema-toFormatter.ts 18.65 KB 18.65 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 22.81 KB 22.81 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.30 KB 19.30 KB 0.00 KB (0.00%)
schema.ts 18.87 KB 18.87 KB 0.00 KB (0.00%)
stm.ts 12.66 KB 12.66 KB 0.00 KB (0.00%)
stream.ts 9.66 KB 9.66 KB 0.00 KB (0.00%)

tim-smart and others added 9 commits August 11, 2026 05:26
…erred-race-wakeup

# Conflicts:
#	packages/effect/test/unstable/workflow/WorkflowEngine.test.ts
Key pendingDeferredResults by executionId like the other per-execution
maps in ClusterWorkflowEngine, and drop the unused deferredWaiters clear
in layerMemory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the waiter-registry plus interrupt-and-replay wake with a
first-class in-process signal. raceAll now races an extra wake arm that
parks on a latch, wins with a deferred completion that arrives while
another branch is active, and is the single place that converts "every
branch suspended" into a durable suspension. Deferred completion just
records the result and opens the latch; the run is never interrupted or
replayed. Also restores the memory-engine live wake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the identity-set branch check with a marker that survives spread
clones, so an await wrapped in DurableDeferred.into still registers with
its race. The guard itself stays: engines capture the calling context
for activity execution, and an unconditional registration lets a
deferred awaited inside an activity body win the race with a non-branch
value (covered by the new clock-capture test). Also strengthen the
memory wake test so removing the live-wake block fails deterministically
and cover the completion-during-suspension-commit window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wake arm no longer returns a raw deferred exit as the race result,
which was only correct when a branch was exactly DurableDeferred.await.
On completion it now re-runs the branch that registered the deferred, so
the branch's own pipeline produces the winner: transformed awaits keep
their transformations and a bare DurableClock.sleep branch returns its
own value. Registrations carry a direct/bubbled flag: a direct await can
be re-run even while its branch is still unwinding through the activity
count, while bubbled registrations from nested races defer to the inner
race until its branch is dead. This also removes the marker guard, and a
durable clock inside a racing activity now wins live via the activity
reset path instead of being ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reviewer verified the bubbled-waits-for-dead-branch guard changes
nothing observable: treating every registration as re-runnable leaves
all suites green with identical work counts across nested race shapes.
The property it guarded is already structural: a parked await never
produces a value, a live nested race handling the same wake can only
produce the same winner, branches are replay-safe, and completions are
idempotent. Deleting the flag also deletes the dead-branch tracking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.0 bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DurableDeferred.raceAll cannot be won by a deferred while another branch is active

1 participant