Fix RewindAsync non-determinism when the failed step is not the last executed step - #1391
Fix RewindAsync non-determinism when the failed step is not the last executed step#1391wangbill (YunchuWang) wants to merge 3 commits into
Conversation
Rewind rebuilt the orchestration history by filtering on event type plus failed-task IDs. That removed the failed task, but kept everything the orchestrator scheduled *because* it observed the failure - an activity invoked from a catch block, a sub-orchestration, a sent event, or the delay timer RetryInterceptor always creates after the final failed attempt of ScheduleWithRetry. Those leftover scheduling events carry sequence IDs the replayed orchestrator can never reach, because after the rewind the failure is invisible and the orchestrator blocks awaiting the re-scheduled task. Replay then hits the orphan and throws NonDeterministicOrchestrationException, which TaskOrchestrationExecutor converts into a fail-orchestration action - so rewind appeared to "always return" the non-determinism error. Fixes Azure/azure-functions-durable-extension#444. The scrub is now episode-aware. History is divided into episodes delimited by OrchestratorStartedEvent; everything scheduled at or after the episode in which a failure was first observed is removed, along with the events carrying those results (a stale result could otherwise satisfy a different task assigned the same sequence ID). All four event types replay matches against the orchestrator's sequence-ID counter are covered: TaskScheduled, SubOrchestrationInstanceCreated, TimerCreated and EventSent. Fan-out/fan-in is unaffected: parallel branches are scheduled in an episode before the failure is observed, so they are retained. Failed sub-orchestrations are likewise created before the episode that delivers their failure, so their creation event is retained and the child rewind message is still emitted. Applied in both live rewind implementations: - TaskOrchestrationDispatcher.ProcessRewindOrchestrationDecision (SDK layer) - AzureTableTrackingStore.RewindHistoryAsync (Azure Storage) Out-of-repo backends that replicate the SDK-layer scrub server-side (e.g. the Durable Task Scheduler) must apply the same rule; the WARNING comment on ProcessRewindOrchestrationDecision now spells out the contract. Tests: new Test/DurableTask.Core.Tests/RewindTests.cs drives real orchestrations through real episodes, rewinds, and replays (8 cases; the 4 regression cases fail without this change). Two end-to-end scenario tests added for the Azure Storage path, both of which reproduce the issue-444 error without the fix. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes RewindAsync non-determinism by making the rewind scrub “episode-aware”, ensuring that any work scheduled after a failure is first observed (and the corresponding result events) is removed so the rewound history can always replay deterministically. It applies the same rule both in the SDK-layer scrub (TaskOrchestrationDispatcher.ProcessRewindOrchestrationDecision) and the Azure Storage backend scrub (AzureTableTrackingStore.RewindHistoryAsync), and adds regression/E2E coverage for the previously failing patterns (cleanup work in catch blocks, retry timers, etc.).
Changes:
- Update Core rewind history scrubbing to remove failure-consequence scheduled events (TaskScheduled/SubOrchestrationCreated/TimerCreated/EventSent) and their results, based on episode boundaries.
- Update Azure Table rewind scrubbing to match the same episode-aware rule over stored history entities.
- Add new rewind regression tests (Core) and new Azure Storage end-to-end rewind scenarios.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| Test/DurableTask.Core.Tests/RewindTests.cs | Adds new Core rewind tests to validate replayability after episode-aware scrubbing (note: currently placed under Test/). |
| test/DurableTask.AzureStorage.Tests/AzureStorageScenarioTests.cs | Adds two E2E Azure Storage rewind tests plus supporting orchestrations/activities for cleanup + retry cases. |
| src/DurableTask.Core/TaskOrchestrationDispatcher.cs | Implements episode-aware rewind scrub and expands the contract comment to keep backend implementations in sync. |
| src/DurableTask.AzureStorage/Tracking/AzureTableTrackingStore.cs | Implements the equivalent episode-aware scrub for Azure Table history entities. |
Suppressed comments (1)
test/DurableTask.AzureStorage.Tests/AzureStorageScenarioTests.cs:1491
- Same issue as the cleanup test: if an assertion throws,
HelloFailRetryActivity.ShouldFailmay remain flipped andhost.StopAsync()won't run, which can impact subsequent tests. Atry/finallyensures both the flag and the host lifecycle are always reset.
Activities.HelloFailRetryActivity.ShouldFail = true;
await host.StartAsync();
string singletonInstanceId = $"Test_{Guid.NewGuid():N}";
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
| namespace DurableTask.Core.Tests | ||
| { | ||
| using System; |
There was a problem hiding this comment.
Fixed in 0759af8 — moved to test/DurableTask.Core.Tests/RewindTests.cs. Git recorded it as a rename ({Test => test}/DurableTask.Core.Tests/RewindTests.cs), so the file now sits next to the .csproj.
One correction to the reasoning, for the record: the tests were compiled and executed in CI. eng/ci/public-build.yml runs on 1es-windows-2022 (os: windows), and NTFS is case-insensitive, so Test\ and test\ resolve to the same physical directory and the SDK glob picked the file up. I verified this locally — the tests ran and the negative controls behaved as expected.
That said, the underlying defect is real and worth fixing: on any case-sensitive filesystem the file would land in a separate directory and be silently dropped from the build. Silent is the dangerous part — the suite would still go green, just with less coverage.
While fixing this I noticed the repo already has pre-existing strays in the same shape, which is presumably how I tripped over it:
Test/DurableTask.Core.Tests/ScheduleTaskOptionsTests.cs
Test/DurableTask.Core.Tests/TaskOrchestrationContextTests.cs
Test/DurableTask.Core.Tests/TraceHelperTests.cs
Test/DurableTask.Core.Tests/VersionSettingsTests.cs
Test/DurableTask.AzureStorage.Tests/OrchestrationSessionTests.cs
Test/DurableTask.AzureStorage.Tests/QueueClientEncodingStrategyIntegrationTests.cs
Test/DurableTask.AzureStorage.Tests/Storage/DurableTaskStorageExceptionTests.cs
For comparison, test/DurableTask.Core.Tests/ (lowercase) holds 21 files including the project file, so lowercase is clearly the intended location. I've deliberately left those 7 alone to keep this PR scoped to the rewind fix — happy to send a separate cleanup PR if you'd like.
| Assert.AreEqual( | ||
| 0, | ||
| result.RewoundHistory.OfType<TaskCompletedEvent>().Count(e => e.TaskScheduledId == 3), | ||
| "The result of the activity scheduled from the catch block should have been removed too."); |
There was a problem hiding this comment.
Good catch — fixed in 0759af8, and the suggestion turned out to be more valuable than a cosmetic cleanup.
Two changes:
- The hard-coded
3is now read back from the pre-rewind history, so the assertion tracks the cleanup activity rather than a fixed sequence ID:
int cleanupTaskId = result.HistoryAtFailure
.OfType<TaskScheduledEvent>()
.Single(e => e.Name == "Cleanup")
.EventId;- Added the general invariant you suggested as
AssertNoOrphanedResultEvents, called fromAssertReplayableso every test in the file gets it. It covers all four scheduling/result pairs:TaskScheduled←TaskCompleted/TaskFailed,SubOrchestrationInstanceCreated←SubOrchestrationInstanceCompleted/Failed, andTimerCreated←TimerFired.
To check the new assertion wasn't just decoration, I neutralized only the result-event removal in the rebuild loop (line 1526, TryGetCompletedTaskId(...) && consequenceTaskIds.Contains(...)) and left the rest of the fix intact. Result:
- The replay assertion passed — no
NonDeterministicOrchestrationExceptionat all. - Three tests failed on the new orphan check:
Found TaskCompletedEvent event(s) with no matching TaskScheduledEvent
in the rewound history (sequence ID(s): 3)
Found TimerFiredEvent event(s) with no matching TimerCreatedEvent
in the rewound history (sequence ID(s): 1, 3)
Found SubOrchestrationInstanceCompletedEvent event(s) with no matching
SubOrchestrationInstanceCreatedEvent in the rewound history (sequence ID(s): 1)
So orphaned result events are a failure mode the replay assertion cannot observe on its own — the history replays cleanly and the danger only materialises later, when a stale result silently satisfies a different task assigned the same sequence ID. The old assertion would have caught one of those three, and only because 3 happened to be right.
Full suite after the change: 8/8 rewind tests pass on net8.0 and net48; Core suite 147/147 on net8.0.
| Activities.HelloFailCleanupActivity.ShouldFail = true; | ||
| await host.StartAsync(); | ||
|
|
||
| string singletonInstanceId = $"Test_{Guid.NewGuid():N}"; | ||
|
|
||
| var client = await host.StartOrchestrationAsync( | ||
| typeof(Orchestrations.SayHelloWithActivityFailAndCleanup), | ||
| input: "World", | ||
| instanceId: singletonInstanceId); | ||
|
|
||
| var statusFail = await client.WaitForCompletionAsync(TimeSpan.FromSeconds(30)); | ||
|
|
||
| Assert.AreEqual(OrchestrationStatus.Failed, statusFail?.OrchestrationStatus); | ||
|
|
||
| Activities.HelloFailCleanupActivity.ShouldFail = false; | ||
|
|
||
| await client.RewindAsync("Rewind orchestrator that scheduled an activity from its catch block."); | ||
|
|
||
| var statusRewind = await client.WaitForCompletionAsync(TimeSpan.FromSeconds(30)); | ||
|
|
||
| Assert.AreEqual(OrchestrationStatus.Completed, statusRewind?.OrchestrationStatus); | ||
| Assert.AreEqual("\"Hello, World!\"", statusRewind?.Output); | ||
|
|
||
| await host.StopAsync(); | ||
| } |
There was a problem hiding this comment.
Agreed — fixed in 0759af8 for both tests (the one flagged here and the RewindActivityFailWithRetry one from the suppressed comment).
Both now capture the flag's original value and restore it in a finally that also stops the host:
bool originalShouldFail = Activities.HelloFailCleanupActivity.ShouldFail;
try
{
Activities.HelloFailCleanupActivity.ShouldFail = true;
...
}
finally
{
Activities.HelloFailCleanupActivity.ShouldFail = originalShouldFail;
await host.StopAsync();
}Worth noting that using alone was not sufficient here: TestOrchestrationHost.Dispose() only calls this.worker.Dispose() and never StopAsync(), so the graceful shutdown really was being skipped on an assertion failure rather than merely deferred.
Verified by running all nine Rewind* tests in AzureStorageScenarioTests together against Azurite — 9/9 pass, so the new tests don't leak state into the existing RewindActivityFail* neighbours that share this pattern.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Test/DurableTask.Core.Tests/RewindTests.cs:14
- These regression tests are added under uppercase
Test/, but the active SDK project andDurableTask.slnreference lowercasetest/DurableTask.Core.Tests(DurableTask.Core.Tests.csproj:1-28,DurableTask.sln:26). On case-sensitive checkouts this file is outside the project directory, so none of these eight tests are compiled or run. Move it totest/DurableTask.Core.Tests/RewindTests.cs.
namespace DurableTask.Core.Tests
- Move RewindTests.cs from Test/ to test/DurableTask.Core.Tests/ to match the location of the test project file. Windows CI is case-insensitive so the tests did compile and run, but the file would be silently excluded from the build on any case-sensitive filesystem. - Replace the hard-coded "TaskScheduledId == 3" assertion with the cleanup task's actual sequence ID read back from the pre-rewind history, and add a general AssertNoOrphanedResultEvents check to AssertReplayable that verifies every result event still refers to a surviving scheduling event. This covers all four scheduling/result pairs and catches stale results that a replay-only assertion cannot see. - Wrap the two new end-to-end rewind tests in try/finally so the shared ShouldFail flag is restored and the host is stopped even when an assertion throws, preventing a failure from cascading into unrelated tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9b6bb49d-7dc5-4abf-86f5-d453fcfc28a4
| || eventType == nameof(EventType.SubOrchestrationInstanceCreated) | ||
| || eventType == nameof(EventType.TimerCreated) | ||
| || eventType == nameof(EventType.EventSent); |
There was a problem hiding this comment.
Good catch — fixed in 99f3d05.
You are right that IsScheduledEventType has four branches but the end-to-end tests only drove two of them (TaskScheduled via the cleanup-activity test, TimerCreated via the retry test). Added two storage-backed scenarios matching the Core cases:
RewindActivityFailWithCleanupSubOrchestration— catch block startsCleanupChildWorkflow, exercising theSubOrchestrationInstanceCreated/SubOrchestrationInstanceCompletedpair.RewindActivityFailWithSendEvent— catch block callscontext.SendEventon the orchestration itself, exercising theEventSentbranch.
Verified these actually guard the branches rather than just passing. Surgically removed only SubOrchestrationInstanceCreated and EventSent from IsScheduledEventType (leaving the rest of the change intact) and re-ran:
Failed RewindActivityFailWithCleanupSubOrchestration
Non-Deterministic workflow detected: A previous execution of this orchestration
scheduled a sub-orchestration task with sequence ID 1 and name '...CleanupChildWorkflow' ...
Failed RewindActivityFailWithSendEvent
Non-Deterministic workflow detected: A previous execution of this orchestration
scheduled a send event task with sequence ID 1, type 'EventSent' name 'ActivityFailed' ...
Each test fails naming its own event type, so the two tests are not redundant with each other. Restoring the branches makes them pass again, and all 11 Rewind* end-to-end tests pass together (no cross-test state leakage).
One note on the sub-orchestration test: CleanupChildWorkflow deliberately schedules no work of its own. TestOrchestrationHost only resolves [KnownType] one level deep, so a child that scheduled an activity would hang on an unregistered activity type. The events under test live in the parent's history and are emitted regardless of what the child does internally.
…tests The Azure Storage history scrub in AzureTableTrackingStore is an independent implementation of the episode-aware rewind rule, but the end-to-end tests only exercised the TaskScheduled and TimerCreated branches of IsScheduledEventType. The SubOrchestrationInstanceCreated and EventSent branches could regress without any Azure Storage test failing. Add two storage-backed rewind scenarios that mirror the corresponding Core cases: one whose catch block starts a sub-orchestration, and one whose catch block raises an event on the orchestration itself. Verified by negative control: removing SubOrchestrationInstanceCreated and EventSent from IsScheduledEventType makes both new tests fail with the original "Non-Deterministic workflow detected" error, each naming its own event type. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9b6bb49d-7dc5-4abf-86f5-d453fcfc28a4
Fixes Azure/azure-functions-durable-extension#444
Problem
RewindAsyncfails withNon-Deterministic workflow detectedwhenever the failed step was not the last thing the orchestrator did before failing. The most common shape is atry/catchwhere the catch block schedules something (a cleanup activity, a sub-orchestration, an external event) before rethrowing:This was confirmed by Katy Shimizu (@kashimiz) back in 2018 ("the rewind process's cleanup phase fails to scrub the history events of the first
FN.DispatchSignalREvent... the failed step in the orchestrator must be the last step executed before the orchestrator itself fails. This is due to a logical oversight in our implementation") and reactivated by Chris Gillum (@cgillum) "so that we don't forget to actually fix this."Root cause
ProcessRewindOrchestrationDecisionrebuilt the history by filtering only on event type + failed task IDs:That correctly removes the failed task, but it retains every event the orchestrator scheduled as a consequence of observing that failure. After rewind the failure is no longer visible in the history, so on replay the orchestrator takes the success path and blocks awaiting the re-scheduled activity. It never reaches the sequence ID of the leftover
TaskScheduledEvent, andTaskOrchestrationContext.HandleTaskScheduledEventthrowsNonDeterministicOrchestrationException.This is exactly why the failure only shows up when the failed step isn't the last one — if nothing was scheduled after the failure, there is nothing left over to trip on.
Fix
Make the scrub episode-aware. History is divided into episodes delimited by
OrchestratorStartedEvent; an episode boundary is precisely where the orchestrator observed new results and reacted to them.failedTaskIdsandfailureEpisode= the earliest episode in which any failure is delivered.consequenceTaskIds= sequence IDs of everything scheduled at episode >=failureEpisode.Dropping the result events matters: a stale result left behind could otherwise satisfy a different task that later gets assigned the same sequence ID.
Two details that are easy to miss and are handled here:
TaskScheduledEvent,SubOrchestrationInstanceCreatedEvent,TimerCreatedEvent,EventSentEvent. All four are scrubbed.RetryInterceptoralways creates a delay timer after the final failed attempt, soScheduleWithRetryleaves behind aTimerCreatedEventthat is itself a consequence of the failure. Without removing it, rewinding a retried activity produces the timer variant of the same error (scheduled a timer task with sequence number 1 ...).The same rule is applied to the Azure Storage rewind path in
AzureTableTrackingStore.RewindHistoryAsync, which is whatAzureStorageOrchestrationService.RewindTaskOrchestrationAsyncactually calls.Behaviors deliberately preserved
SubOrchestrationInstanceCreatedEventprecedes the episode that delivers the failure, so it is retained and the child rewind message is still emitted.Known tradeoff
Within the failure episode the scrub errs on the side of removing too much. Without re-running orchestrator code there is no way to distinguish "scheduled because of the failure" from "unrelated work that happened to be batched into the same episode". The cost is that a small number of successful tasks may be re-executed on rewind; the benefit is a history that always replays. Given that rewind is an explicit, manual recovery operation on an already-failed instance, and that the alternative is rewind failing outright, this is the right trade. Activities should already be idempotent for rewind to be meaningful at all.
Note for other backends
ProcessRewindOrchestrationDecisionis not the only implementation of this scrub — some backends (notably the Durable Task Scheduler) replicate it server-side. TheWARNINGcomment above the method has been expanded into an explicit contract describing the rule so those implementations can be kept in sync. They will still exhibit this bug until updated.Tests
New
Test/DurableTask.Core.Tests/RewindTests.cs(8 tests) drives real orchestrations through real episodes, rewinds, and replays the result.Regression tests (fail without the fix):
Rewind_CatchBlockSchedulesActivity_ProducesReplayableHistoryRewind_CatchBlockCreatesSubOrchestration_ProducesReplayableHistoryRewind_CatchBlockSendsEvent_ProducesReplayableHistoryRewind_ScheduleWithRetry_RemovesRetryTimersGuard tests (protect existing behavior):
Rewind_SimpleActivityFailure_ReschedulesOnlyTheFailedActivityRewind_FanOutFanIn_RetainsSuccessfulBranchesRewind_FailedSubOrchestration_RetainsCreationAndEmitsChildRewindMessageRewind_AssignsNewExecutionIdTwo end-to-end tests in
AzureStorageScenarioTests.cscover the Azure Storage path against real storage:RewindActivityFailWithCleanupActivityandRewindActivityFailWithRetry.Verification
Both halves of the change were validated with A/B controls rather than by assuming the tests are meaningful.
AzureTableTrackingStorereverted to pre-fixThe pre-fix E2E failures reproduce the original 2018 report verbatim:
and, for the retry variant:
Note: net48 has 7 pre-existing failures in the Core suite (all
TraceHelper_*/Dispatcher_RestartsTraceActivity_ForContinueAsNewStartNewTrace). These were confirmed against a baseline with this change removed (7/127 before vs 7/135 after) and are unrelated to rewind.