Skip to content

Rewind after a multi-failed-sibling WhenAll deadlocks the UNSIGNALED sibling subtree (emit-once replay can't reconcile a re-driven orphan with no rewind signal) #301

Description

@YunchuWang

Summary

PR #300 (fixes #299) closes the activity-level rewind orphan: after a server-side (Azure Storage / DurableTask.Core) rewind, a Task.all fan-out with multiple failed activity siblings now re-dispatches all of them on the rewound (signaled) path.

However, the extension E2E RewindOrchestratorTests.RewindFailedOrchestration_ShouldSucceed(1|2) (backend = Azure Storage) exercises a two-level version of the same pattern and still deadlocks, for a distinct, deeper reason that #300 does not — and by design cannot safely — address. This issue tracks that residual gap.

VERIFIED (from live Azure Storage worker logs)

Test / topology. The test asserts the root reaches Completed (RewindOrchestratorTests.cs:50). The orchestrator (test/e2e/Apps/.../functions/RewindOrchestration.ts) is two levels deep:

Mechanism.

  1. The root Task.all fails fast on the first sub-orchestration failure. The second failing sub-orch's failure is a late completion for a now-terminal parent and is dropped (never committed) — the same drop mechanism Fix rewind deadlock when Task.all had multiple failed siblings (#299) #300 describes, one level up.
  2. DurableTask.Core server-side rewind therefore rewinds only the committed-failed subtree and wakes only that subtree's grandchild with the revival GenericEvent.
  3. The Fix rewind deadlock when Task.all had multiple failed siblings (#299) #300 fix reaches the signaled grandchild and works: it logs Waiting for 2 → Returning 2 (both failed activity siblings re-dispatched) and completes.
  4. The other failing subtree is re-driven by the root's replay with no rewind signal. Its grandchild hits the identical Rewind of nested orchestration deadlocks when a Task.all had multiple failed activities (only one failed sibling re-dispatched on replay) #299 activity orphan, but the reconcile is (correctly) gated on the rewind signal (isRewindReplay), so it cannot fire → Waiting for 2 → Returning 0 → deadlock → root never reaches Completed → 30s timeout.

Verbatim per-grandchild evidence (numFailures=2, single-TFM, one clean instance):

grandchild instance rewind GenericEvent inner Task.all outcome
…:0002:0002 2 Returning 2FailChildSubOrchestration completed
…:0003:0002 0 stuck at Waiting for 2 → Returning 0, never completes ❌

The dropped branch is non-deterministic (a race): in a numFailures=1 run the orphaned branch was …:0002:0002 (GenericEvent=0) while …:0003:0002 was signaled and completed — the opposite of the numFailures=2 run above.

Zero NonDeterminismError in every run (numFailures 1 & 2, pass and fail). The line-50 failures are deadlocks, not throws — so this is distinct from the duplicate-TaskScheduled concern #300's idempotent guard addresses.

Why the reconcile gate cannot simply be removed. The worker cannot distinguish a stale orphan (no completion will ever arrive — must re-dispatch) from a genuinely in-flight scheduled-but-uncompleted task (completion coming later — must not re-dispatch) without a rewind signal. Un-gating the reconcile would re-dispatch in-flight activities during ordinary replay → double execution. The signal (GenericEvent / ExecutionRewound) is the only structural discriminator, and the backend sends it only to the rewound subtree.

Root-cause framing (why v3 / DurableTask.Core pass and this doesn't)

The classic durable-functions v3 SDK (and DurableTask.Core) pass this same E2E because their replay model re-emits the full action set every episode and the backend dedupes via TaskScheduled. An unsignaled re-driven orphan is harmless there — the action is simply re-emitted and de-duplicated.

durabletask-js (and durabletask-python) use an emit-once + gated-reconcile replay model, which is not inherently robust to a missing rewind signal. So the class "rewind after a multi-failed-sibling WhenAll" is only partially closed by #300 (the signaled, activity-level path); the unsignaled sub-orchestration path remains open.

durabletask-python shares this exact gap (see #300's Sister-SDK parity note): the same pop-orphan taskScheduled handler, the same get_actions() = list(_pending_actions.values()), and no rewind-revival recovery.

HYPOTHESIS / candidate directions (need design + cross-SDK review — NOT prescribed here)

  • (B) Durably record all WhenAll sibling terminal outcomes before the instance goes terminal, so server-side rewind rewinds every failed path and every subtree receives a revival signal. Removes the drop at the source; needs care that the user-visible fail-fast result semantics are preserved while the sibling failures are still persisted.
  • (C) A v3-parity re-emit/dedup replay-model change in the worker, so a missing signal is no longer load-bearing. Larger, cross-cutting change.

Both need design review and should be coordinated with durabletask-python (shared gap). This issue deliberately does not prescribe a direction.

Scope / relationship to #299 and #300

Refs: #299, #300.

Fidelity note

The live E2E was run with the #300 fix logic ported onto the #282 (gRPC-worker) core — the fix anchors are byte-identical — with callEntities=false and a single target framework (--framework net8.0) to force a single deterministic instance. The residual-failure mechanism is core-level (WhenAll fail-fast drop + backend rewind scoping + gated reconcile), independent of that harness detail.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions