[DurableTask.ServiceBus] Fixed executionId being ignored in ServiceBusOrchestrationService.WaitForOrchestrationAsync - #1403
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved timeout logic and inactive regression tests must be addressed.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR fixes execution tracking, continuation handling, status polling, and timeout behavior in WaitForOrchestrationAsync.
Changes:
- Honors execution IDs and follows
ContinueAsNewgenerations. - Handles suspended states and zero, negative, and infinite timeouts.
- Adds in-memory regression tests.
File summaries
| File | Summary | Final findings |
|---|---|---|
Test/DurableTask.ServiceBus.Tests/WaitForOrchestrationTests.cs |
Adds wait-behavior regression tests. | moderate (3 votes): Tests are outside the active lowercase test/ path and are not compiled. moderate (1 vote): Infinite-timeout cancellation coverage does not prove the wait remained incomplete before cancellation. |
src/DurableTask.ServiceBus/ServiceBusOrchestrationService.cs |
Updates execution-aware polling and timeout handling. | moderate (3 votes): ContinuedAsNew skips timeout accounting and the zero-timeout exit. moderate (1 vote): Timeout accounting before the delay can return early. nit (1 vote): The validation message omits TimeSpan.Zero. |
Review details
Suppressed comments (3)
Test/DurableTask.ServiceBus.Tests/WaitForOrchestrationTests.cs:475
- This test does not verify that cancellation caused the wait to finish: an implementation that incorrectly returns
nullimmediately forTimeout.InfiniteTimeSpanwould pass theAssert.IsNullassertion before the three-second token cancellation. Assert that the wait remains incomplete until the token is canceled, then accept eithernullorOperationCanceledException.
OrchestrationState state = await service.WaitForOrchestrationAsync(
InstanceId,
"generation-1",
Timeout.InfiniteTimeSpan,
cts.Token);
Assert.IsNull(state, "A cancelled wait must not return a state.");
}
catch (OperationCanceledException)
src/DurableTask.ServiceBus/ServiceBusOrchestrationService.cs:1299
- The timeout is decremented and tested before the delay, so a non-terminal wait returns one polling interval early: a 4-second timeout exits after about 2 seconds, and any positive timeout up to 2 seconds returns immediately. This can report a timeout while the caller's requested wait window is still available. Cap the delay by the remaining timeout, await it, and decrement after the delay (while retaining the zero-timeout no-delay case).
timeout -= StatusPollingInterval;
// For a user-provided timeout of `TimeSpan.Zero`,
// we want to check the status of the orchestration once and then return.
// Therefore, we check the timeout condition after the status check.
if (timeout <= TimeSpan.Zero)
{
break;
src/DurableTask.ServiceBus/ServiceBusOrchestrationService.cs:1250
- This exception message omits
TimeSpan.Zero, even though zero is explicitly accepted by the validation and documented as a valid timeout. A caller following the guidance would incorrectly avoid a supported value; mention zero in the list of valid choices.
$" Please provide either a positive timeout value or Timeout.InfiniteTimeSpan.");
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
… of https://github.com/davidemontanari/durabletask into davidemontanari/dtfx-sb-orchestration-wait-executionid
There was a problem hiding this comment.
🟡 Changes recommended
Address the timeout polling bug and ensure the new tests are included and validate cancellation.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
Test/DurableTask.ServiceBus.Tests/WaitForOrchestrationTests.cs:502
- This assertion does not prove that cancellation was observed: the previous
Timeout.InfiniteTimeSpanbug returnednullimmediately, which also satisfiesAssert.IsNullbefore the 3-second token fires. Because theOperationCanceledExceptionpath is accepted without any assertion, this test would pass for both behaviors; assert that the store was polled beyond the initial lookup (or otherwise verify the wait remained active) in both paths.
Assert.IsNull(state, "A cancelled wait must not return a state.");
}
catch (OperationCanceledException)
{
// Also acceptable: the polling delay observes the token directly.
}
Test/DurableTask.ServiceBus.Tests/WaitForOrchestrationTests.cs:14
- This new test file is under
Test/, but the solution and active ServiceBus test project are under lowercasetest/(DurableTask.sln:8,test/DurableTask.ServiceBus.Tests/DurableTask.ServiceBus.Tests.csproj:42-47). There is no project underTest/, so SDK compile globs will not include these tests and CI will not execute the cases added here. Move the file into the activetest/DurableTask.ServiceBus.Tests/directory (or explicitly include it in that project).
namespace DurableTask.ServiceBus.Tests
src/DurableTask.ServiceBus/ServiceBusOrchestrationService.cs:1250
- The new exception text omits
TimeSpan.Zero, even though this method explicitly accepts zero and performs one status check for it (see the updated XML documentation and branch below). A caller following this error message would unnecessarily reject a valid timeout; mentionTimeSpan.Zeroor say that non-negative values are accepted.
$" Please provide either a positive timeout value or Timeout.InfiniteTimeSpan.");
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
The regression tests must be included in the active project and strengthened to verify infinite-timeout polling.
Review details
Suppressed comments (2)
Test/DurableTask.ServiceBus.Tests/WaitForOrchestrationTests.cs:31
- These tests are added under
Test/DurableTask.ServiceBus.Tests, butDurableTask.sln:8and the active project use the lowercasetest/DurableTask.ServiceBus.Testspath, and there is noTest/**/*.csproj. As a result, this entire regression suite is excluded from compilation and CI; move the file into the activetest/project so the new behavior is actually covered.
[TestClass]
public class WaitForOrchestrationTests
Test/DurableTask.ServiceBus.Tests/WaitForOrchestrationTests.cs:563
- The non-throwing branch only asserts that the result is null, so the previous implementation's
Timeout.InfiniteTimeSpanbug (returning null before performing any lookup) would pass this test. Assert that the store was queried (and preferably drive cancellation after a poll) so this test actually verifies that the infinite wait remains active until cancellation.
Assert.IsNull(state, "A cancelled wait must not return a state.");
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
- Fixed assets WaitForOrchestration_Timeout_Infinite_HonorsCancellation test for robustness
There was a problem hiding this comment.
🔵 Needs a closer look
Move the regression tests into the referenced lowercase test project directory so they are built and run by CI.
Review details
Suppressed comments (1)
Test/DurableTask.ServiceBus.Tests/WaitForOrchestrationTests.cs:14
- This test file is under the uppercase
Test/DurableTask.ServiceBus.Teststree, butDurableTask.slnand the SDK test project use the lowercasetest/DurableTask.ServiceBus.Testspath. Because SDK default compile items do not include sibling directories, these regression tests are not built or run by the solution/CI; move the file into the referenced lowercase test project directory.
namespace DurableTask.ServiceBus.Tests
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
A timeout test relies on a scheduler-dependent wall-clock assertion and should be made deterministic.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
Timestamp equality can allow an older execution to pass the stale-row filter.
Review details
Suppressed comments (1)
src/DurableTask.ServiceBus/ServiceBusOrchestrationService.cs:1277
- The strict comparison does not reliably exclude an older execution:
CreatedTimecomes fromHistoryEvent.Timestamp, which is assigned fromDateTime.UtcNowand is not guaranteed unique. If a previous terminal row shares the ContinueAsNew tombstone's timestamp, it passes this<check and can be returned before the next generation is readable. Use a generation-safe discriminator or another explicit exclusion mechanism instead of relying on timestamp ordering alone.
if (state?.CreatedTime < minimumCreatedTime)
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
The unpinned lookup can still return stale prior execution results before the next generation is readable.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
src/DurableTask.ServiceBus/ServiceBusOrchestrationService.cs:1260
- The null/empty/whitespace execution-id path still calls the store's non-pinned lookup, which excludes
ContinuedAsNewrows in the bundled Service Bus store. During the gap after a generation writes its tombstone but before the next generation is readable, that query can therefore return an older completed execution and this method immediately returns it. Query state in a way that includes the tombstone (or otherwise carries a generation floor) before accepting a terminal result for the current-generation API.
OrchestrationState state = pinnedToExecution
? await GetOrchestrationStateAsync(instanceId, executionId)
: (await GetOrchestrationStateAsync(instanceId, false))?.FirstOrDefault();
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
Bernd Verst (berndverst)
left a comment
There was a problem hiding this comment.
Reviewed commit beaa47c. I recommend fixing the two inline P2 findings before merging:
- The new CreatedTime floor can reject a valid ContinueAsNew successor when the initiating client's clock is ahead of the worker's, causing a false timeout or an indefinite wait.
- The execution-specific lookup bypasses AzureTableInstanceStore's existing outer retry policy, so transient storage failures that previously recovered now fault the wait.
Validation: the PR's 23 tests pass on both net8.0 and net48. I reproduced both regressions on both targets; the corresponding regression cases pass with the exact pre-PR production implementation on net8.0. The retry reproduction uses the actual AzureTableInstanceStore with an injected table-client failure. Live Service Bus/Storage integration tests were not run.
Separately, the stale-result protection remains incomplete: omitted execution IDs retain the race acknowledged in the PR description, and a previous completed run whose CreatedTime and LastUpdatedTime both equal the tombstone's values still passes the new filter. I reproduced the equal-timestamp case as well; these are remaining limitations rather than additional newly introduced regressions.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Two moderate findings remain unresolved regarding timestamp filtering and retry timeout/cancellation behavior.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (1)
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
A critical clock-skew scenario can still return stale execution output, and the documentation does not match the implementation.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1


Fix
executionIdbeing ignored inServiceBusOrchestrationService.WaitForOrchestrationAsyncProblem
WaitForOrchestrationAsyncaccepted anexecutionIdparameter but never used it, always querying by instance id only. This relied on perfect instance-store synchronization to detect a new pending execution.For recurring orchestrations this caused a race: if the status was checked before the new pending execution became readable from storage, the wait returned the previous execution's completed state. The caller treated the orchestration as complete and scheduled the next one, which then never executed because the previous execution had not actually finished.
Fixing that surfaced several adjacent defects in the same method, all addressed here.
Changes
1. Honor
executionIdQuery explicitly by execution id when one is supplied, so the wait tracks the execution it was asked about rather than whatever row happens to be newest:
string.IsNullOrWhiteSpaceis used rather than anullcheck so an empty or whitespaceexecutionIdstill means "current generation", consistent withLocalOrchestrationService.2. Stop pinning when the execution continues-as-new
In the Service Bus store, rows are keyed by
InstanceId + ExecutionIdand are write-once, so aContinuedAsNewrow is a permanent tombstone — the live orchestration has moved to a new execution id. Continuing to poll the pinned id would block until timeout, so on seeing one we un-pin and follow the current generation.The tombstone still counts as that iteration's status check, so the loop falls through to the normal timeout accounting instead of re-querying immediately. Re-querying would let a
TimeSpan.Zerowait perform two lookups and reach the next generation, contradicting the documented single-check behavior.This also avoids returning the
ContinuedAsNewstate itself, whoseOutputis not the orchestration result but the next generation's input — it would deserialize into a plausible but wrong value rather than failing loudly.3. Treat
SuspendedandContinuedAsNewas non-terminalThe status check only special-cased
RunningandPending. A suspended orchestration is resumable viaExecutionResumedEventand has a nullOutput, so returning it as terminal is incorrect.ContinuedAsNewis likewise never final.The bundled
AzureTableInstanceStorehappens to filterContinuedAsNewrows out of the non-pinned lookup, which is what masked this, but that filtering is an implementation detail and is not required byIOrchestrationServiceInstanceStore. The guard makes the method correct for any conforming store.4. Timeout handling
Timeout.InfiniteTimeSpanis-1ms, so the previoustimeoutSeconds > 0loop condition returnednullimmediately instead of waiting forever. Infinite is now detected once up front and the decrement skipped entirely, since subtracting from-1msalso trips the negative check. Negative timeouts now throwArgumentExceptioninstead of silently returningnull.StatusPollingIntervalInSecondsbecame aTimeSpanto remove manual* 1000conversions.5. Poll for the full timeout window
The budget was decremented by a whole polling interval before the delay it was paying for, and the loop exited without ever performing that final delay and status check. A 4 second wait polled at t=0 and t=2 and then returned
nullat t=2, discarding half its window and potentially reporting a timeout while the orchestration completed during the remainder. Any timeout shorter than the 2 second interval returned immediately without waiting at all.The deadline is now checked before the budget is spent, only time actually spent waiting is charged, and the final delay is clamped to what remains:
A 4 second timeout now polls at roughly t=0, t=2 and t=4; a 1 second timeout waits out its window without overshooting it; and
TimeSpan.Zerostill performs exactly one status check.6. Preserve the store's retry policy for execution-pinned lookups
AzureTableInstanceStorewraps the instance-id overload's table queries inUtils.ExecuteWithRetries, butGetOrchestrationStateAsync(instanceId, executionId)queried the state and JumpStart tables directly. Change routes polling through the pinned overload, so without this the two paths would have had different failure behavior: a single transient failure escaping the storage SDK's own retries would fault the entire wait.Both queries in the pinned overload are now wrapped to match:
To make this testable,
AzureTableClient.QueryOrchestrationStatesAsyncandQueryJumpStartOrchestrationsAsyncare nowvirtual, with a newinternal AzureTableInstanceStore(AzureTableClient)constructor. This adds no public API surface:AzureTableClientis aninternaltype, sopublicmembers on it are capped at internal accessibility and can only be overridden by the threeInternalsVisibleTotest assemblies. It carries no versioning commitment and can be reverted without a breaking change.Tests
New
Test/DurableTask.ServiceBus.Tests/WaitForOrchestrationTests.cs— 17 tests (21 cases) using an in-memoryIOrchestrationServiceInstanceStorethat reproducesAzureTableInstanceStorequery semantics, including theContinuedAsNewfilter andLastUpdatedTimeordering. NewAzureTableInstanceStoreRetryTests.cs— 2 tests driving the realAzureTableInstanceStorethrough a stubbed table client. Unlike the existing integration tests, none of these need a live Service Bus or Storage account.Execution tracking:
ContinueAsNewFailed) returned via the pinned lookupSuspend/resume:
Suspended → Running → Completed) returns the final state, asserting it polls through both intermediate statesTimeout parameter:
TimeSpan.Zerochecks exactly once and does not poll, returning an already-terminal state if presentTimeSpan.Zeroagainst aContinuedAsNewtombstone still performs exactly one lookupTimeout.InfiniteTimeSpanwaits for completion and still honors cancellationStore retries:
The
-2msnegative case is deliberate: it sits immediately adjacent toTimeout.InfiniteTimeSpan(-1ms) and pins the boundary between "rejected as negative" and "treated as infinite".Test results: 23/23 passing on both
net8.0(37.7s) andnet48(39s). Each guard above was additionally verified by reverting it in the production code and confirming a specific test fails, so the suite is known to be non-vacuous rather than merely green.Known limitation
Once a continue-as-new tombstone forces the wait to un-pin, a previous run's terminal row can still be returned if it becomes visible before the successor does. This matches pre-existing behavior and is not a regression, but it is not fixed here.
Timestamps cannot close this gap.
LastUpdatedTimeis stamped withDateTime.UtcNowby whichever worker persists each state (Utils.BuildOrchestrationState), andCreatedTimecomes from anExecutionStartedEventproduced on the client for the first generation but on the worker for each continue-as-new. Neither gives a cross-machine ordering guarantee, so under clock skew a previous run can sort after the tombstone while a successor sorts before it. Closing this properly needs a generation or lineage signal independent of wall-clock ordering, and the persisted schema has no such column today —ParentExecutionIdis a sub-orchestration pointer and is copied forward unchanged — so it is left as follow-up.The primary bug is still fixed: callers that pass an
executionIdare pinned to that execution and can never receive a previous run's state.