diff --git a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs
index 6d63f6af..705468eb 100644
--- a/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs
+++ b/src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs
@@ -32,6 +32,29 @@ class Processor
{
static readonly Google.Protobuf.WellKnownTypes.Empty EmptyMessage = new();
+ ///
+ /// Test-only hook invoked immediately after an individual action's size is computed during the
+ /// fail-fast validation pass in . Always
+ /// in production (adds no overhead beyond a null check); used exclusively
+ /// by unit tests (accessed via reflection) to deterministically verify that validation stops at
+ /// the first oversized action instead of computing the size of every action.
+ ///
+#pragma warning disable CS0649 // Field is never assigned to in production code - it is set via reflection by tests.
+ static Action? testActionSizedHook;
+
+ ///
+ /// Test-only hook invoked immediately after the *whole response* is sized (via
+ /// response.CalculateSize()) in .
+ /// Always in production (adds no overhead beyond a null check). Protobuf's
+ /// whole-response sizing recursively sizes every action internally without going through
+ /// , so this separate hook exists to let unit tests prove that
+ /// whole-response sizing is never reached when fail-fast validation rejects an oversized action -
+ /// a regression that reintroduces whole-response sizing *before* validation would otherwise go
+ /// undetected by alone.
+ ///
+ static Action? testResponseSizedHook;
+#pragma warning restore CS0649
+
readonly GrpcDurableTaskWorker worker;
readonly TaskHubSidecarServiceClient client;
readonly DurableTaskShimFactory shimFactory;
@@ -1098,81 +1121,89 @@ async Task CompleteOrchestratorTaskWithChunkingAsync(
int maxChunkBytes,
CancellationToken cancellationToken)
{
- // Validate that no single action exceeds the maximum chunk size
- static P.TaskFailureDetails? ValidateActionsSize(IEnumerable actions, int maxChunkBytes)
+ // Helper to add an action to the current chunk if it fits, using a precomputed action size
+ // so validation and chunk packing do not need to recalculate its serialized size.
+ static bool TryAddAction(
+ Google.Protobuf.Collections.RepeatedField dest,
+ P.OrchestratorAction action,
+ int actionSize,
+ ref int currentSize,
+ int maxChunkBytes)
+ {
+ if (currentSize + actionSize > maxChunkBytes && currentSize > 0)
+ {
+ return false;
+ }
+
+ dest.Add(action);
+ currentSize += actionSize;
+ return true;
+ }
+
+ bool largePayloads = this.worker.grpcOptions.Capabilities.Contains(P.WorkerCapability.LargePayloads);
+
+ // When the LargePayloads capability is not present, validate that no single action
+ // exceeds the maximum chunk size *before* doing any whole-response sizing. This must
+ // stay fail-fast: as soon as an oversized action is found, we fail the orchestration
+ // immediately without computing the size of any later action. Only when every action
+ // is confirmed to individually fit do we keep the sizes we already computed here, so
+ // they can be reused below instead of being recalculated.
+ int[]? actionSizes = null;
+ if (!largePayloads)
{
- foreach (P.OrchestratorAction action in actions)
+ actionSizes = new int[response.Actions.Count];
+ for (int i = 0; i < response.Actions.Count; i++)
{
+ P.OrchestratorAction action = response.Actions[i];
int actionSize = action.CalculateSize();
+ testActionSizedHook?.Invoke(action.Id);
if (actionSize > maxChunkBytes)
{
// TODO: large payload doc is not available yet on aka.ms, add doc link to below error message
string errorMessage = $"A single orchestrator action of type {action.OrchestratorActionTypeCase} with id {action.Id} " +
$"exceeds the {maxChunkBytes / 1024.0 / 1024.0:F2}MB limit: {actionSize / 1024.0 / 1024.0:F2}MB. " +
"Enable large-payload externalization to Azure Blob Storage to support oversized actions.";
- return new P.TaskFailureDetails
+ P.TaskFailureDetails validationFailure = new()
{
ErrorType = typeof(InvalidOperationException).FullName,
ErrorMessage = errorMessage,
IsNonRetriable = true,
};
- }
- }
- return null;
- }
-
- P.TaskFailureDetails? validationFailure = this.worker.grpcOptions.Capabilities.Contains(P.WorkerCapability.LargePayloads)
- ? null
- : ValidateActionsSize(response.Actions, maxChunkBytes);
- if (validationFailure != null)
- {
- // Complete the orchestration with a failed status and failure details
- P.OrchestratorResponse failureResponse = new()
- {
- InstanceId = response.InstanceId,
- CompletionToken = response.CompletionToken,
- OrchestrationTraceContext = response.OrchestrationTraceContext,
- Actions =
- {
- new P.OrchestratorAction
+ // Complete the orchestration with a failed status and failure details
+ P.OrchestratorResponse failureResponse = new()
{
- CompleteOrchestration = new P.CompleteOrchestrationAction
+ InstanceId = response.InstanceId,
+ CompletionToken = response.CompletionToken,
+ OrchestrationTraceContext = response.OrchestrationTraceContext,
+ Actions =
{
- OrchestrationStatus = P.OrchestrationStatus.Failed,
- FailureDetails = validationFailure,
+ new P.OrchestratorAction
+ {
+ CompleteOrchestration = new P.CompleteOrchestrationAction
+ {
+ OrchestrationStatus = P.OrchestrationStatus.Failed,
+ FailureDetails = validationFailure,
+ },
+ },
},
- },
- },
- };
+ };
- await this.ExecuteWithRetryAsync(
- async () => await this.client.CompleteOrchestratorTaskAsync(failureResponse, cancellationToken: cancellationToken),
- nameof(this.client.CompleteOrchestratorTaskAsync),
- cancellationToken);
- return;
- }
+ await this.ExecuteWithRetryAsync(
+ async () => await this.client.CompleteOrchestratorTaskAsync(failureResponse, cancellationToken: cancellationToken),
+ nameof(this.client.CompleteOrchestratorTaskAsync),
+ cancellationToken);
+ return;
+ }
- // Helper to add an action to the current chunk if it fits
- static bool TryAddAction(
- Google.Protobuf.Collections.RepeatedField dest,
- P.OrchestratorAction action,
- ref int currentSize,
- int maxChunkBytes)
- {
- int actionSize = action.CalculateSize();
- if (currentSize + actionSize > maxChunkBytes && currentSize > 0)
- {
- return false;
+ actionSizes[i] = actionSize;
}
-
- dest.Add(action);
- currentSize += actionSize;
- return true;
}
- // Check if the entire response fits in one chunk
+ // Calculate the whole response size exactly once. If it fits in a single chunk, send
+ // it directly without any further per-action work.
int totalSize = response.CalculateSize();
+ testResponseSizedHook?.Invoke();
if (totalSize <= maxChunkBytes)
{
// Response fits in one chunk, send it directly (isPartial defaults to false)
@@ -1183,9 +1214,21 @@ await this.ExecuteWithRetryAsync(
return;
}
+ // Response is too large to fit in a single chunk. Reuse the action sizes computed
+ // above during validation if we have them (LargePayloads not present); otherwise (no
+ // validation was needed) compute each action's serialized size exactly once here. Either
+ // way, the cached sizes are reused for chunk packing below instead of being recalculated.
+ if (actionSizes == null)
+ {
+ actionSizes = new int[response.Actions.Count];
+ for (int i = 0; i < response.Actions.Count; i++)
+ {
+ actionSizes[i] = response.Actions[i].CalculateSize();
+ }
+ }
+
// Response is too large, split into multiple chunks
int actionsCompletedSoFar = 0, chunkIndex = 0;
- List allActions = response.Actions.ToList();
bool isPartial = true;
bool isChunkedMode = false;
@@ -1203,15 +1246,15 @@ await this.ExecuteWithRetryAsync(
int chunkPayloadSize = 0;
// Fill the chunk with actions until we reach the size limit
- while (actionsCompletedSoFar < allActions.Count &&
- TryAddAction(chunkedResponse.Actions, allActions[actionsCompletedSoFar], ref chunkPayloadSize, maxChunkBytes))
+ while (actionsCompletedSoFar < response.Actions.Count &&
+ TryAddAction(chunkedResponse.Actions, response.Actions[actionsCompletedSoFar], actionSizes[actionsCompletedSoFar], ref chunkPayloadSize, maxChunkBytes))
{
actionsCompletedSoFar++;
}
// Determine if this is a partial chunk (more actions remaining)
#pragma warning disable CS0612 // isPartial/chunkIndex are deprecated but still required for chunked response wire compatibility.
- isPartial = actionsCompletedSoFar < allActions.Count;
+ isPartial = actionsCompletedSoFar < response.Actions.Count;
chunkedResponse.IsPartial = isPartial;
// Only activate chunked mode when we actually need multiple chunks.
diff --git a/test/Worker/Grpc.Tests/CompleteOrchestratorTaskWithChunkingTests.cs b/test/Worker/Grpc.Tests/CompleteOrchestratorTaskWithChunkingTests.cs
new file mode 100644
index 00000000..3c451774
--- /dev/null
+++ b/test/Worker/Grpc.Tests/CompleteOrchestratorTaskWithChunkingTests.cs
@@ -0,0 +1,616 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Reflection;
+using FluentAssertions;
+using Grpc.Core;
+using Microsoft.DurableTask.Worker;
+using Microsoft.DurableTask.Worker.Grpc;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Moq;
+using P = Microsoft.DurableTask.Protobuf;
+
+namespace Microsoft.DurableTask.Worker.Grpc.Tests;
+
+///
+/// Serializes tests that use the Processor's static sizing-observability hooks.
+///
+[CollectionDefinition(nameof(CompleteOrchestratorTaskWithChunkingCollection), DisableParallelization = true)]
+public sealed class CompleteOrchestratorTaskWithChunkingCollection
+{
+}
+
+///
+/// Focused unit tests for Processor.CompleteOrchestratorTaskWithChunkingAsync, covering the
+/// size-boundary decisions, the capability combinations,
+/// oversized-single-action failure behavior, chunk wire-compatibility, and retry behavior. These tests
+/// guard against regressions in the optimization that avoids recalculating protobuf action sizes
+/// (see https://github.com/microsoft/durabletask-dotnet/issues/773).
+///
+[Collection(nameof(CompleteOrchestratorTaskWithChunkingCollection))]
+public class CompleteOrchestratorTaskWithChunkingTests
+{
+ [Fact]
+ public async Task ResponseFitsExactlyAtLimit_SendsOriginalResponseDirectly_NoChunking()
+ {
+ // Arrange
+ P.OrchestratorResponse response = BuildResponse(
+ "instance-1",
+ BuildScheduleTaskAction(0, 16),
+ BuildScheduleTaskAction(1, 16));
+ int maxChunkBytes = response.CalculateSize();
+
+ using Fixture fixture = Fixture.Create();
+
+ // Sanity-check the whole-response-sizing hook itself: it must fire exactly once for a
+ // normal (non-fail-fast) request, so that asserting "never invoked" in the fail-fast test
+ // below is meaningful rather than a false positive caused by broken hook wiring.
+ int responseSizedCount = 0;
+ Fixture.SetResponseSizedHook(() => responseSizedCount++);
+ try
+ {
+ // Act
+ await fixture.InvokeAsync(response, maxChunkBytes);
+ }
+ finally
+ {
+ Fixture.SetResponseSizedHook(null);
+ }
+
+ // Assert - the exact same response instance is sent, unmodified, in a single call.
+ responseSizedCount.Should().Be(1);
+ fixture.Sent.Should().HaveCount(1);
+ fixture.Sent[0].Should().BeSameAs(response);
+#pragma warning disable CS0612 // IsPartial/ChunkIndex are deprecated but still part of the wire contract.
+ fixture.Sent[0].IsPartial.Should().BeFalse();
+ fixture.Sent[0].ChunkIndex.Should().BeNull();
+#pragma warning restore CS0612
+ }
+
+ [Fact]
+ public async Task ResponseOneByteOverLimit_ActionsStillFitIndividually_ProducesSingleNonPartialChunk()
+ {
+ // Arrange - the whole response is one byte too large, but rebuilding a chunk from the
+ // (already fitting) individual actions still yields just one, non-partial chunk. This
+ // exercises the chunking code path (not the direct-send fast path) while confirming the
+ // resulting chunk still preserves all wire-compatibility fields correctly.
+ P.OrchestratorResponse response = BuildResponse(
+ "instance-2",
+ BuildScheduleTaskAction(0, 16),
+ BuildScheduleTaskAction(1, 16));
+ response.CustomStatus = "status";
+ response.OrchestrationTraceContext = new P.OrchestrationTraceContext { SpanID = "span-1" };
+ int exactSize = response.CalculateSize();
+
+ using Fixture fixture = Fixture.Create();
+
+ // Act
+ await fixture.InvokeAsync(response, exactSize - 1);
+
+ // Assert
+ fixture.Sent.Should().HaveCount(1);
+ P.OrchestratorResponse sent = fixture.Sent[0];
+ sent.Should().NotBeSameAs(response); // Went through the chunking construction path.
+ sent.InstanceId.Should().Be(response.InstanceId);
+ sent.CompletionToken.Should().Be(response.CompletionToken);
+ sent.CustomStatus.Should().Be(response.CustomStatus);
+ sent.OrchestrationTraceContext.SpanID.Should().Be(response.OrchestrationTraceContext.SpanID);
+ sent.NumEventsProcessed.Should().BeNull();
+ sent.Actions.Should().HaveCount(2);
+#pragma warning disable CS0612
+ sent.IsPartial.Should().BeFalse();
+ sent.ChunkIndex.Should().BeNull();
+#pragma warning restore CS0612
+ }
+
+ [Fact]
+ public async Task ActionExactlyFillsRemainingChunkSpace_IsIncludedInSameChunk()
+ {
+ // Arrange - two actions whose combined size exactly equals maxChunkBytes. The boundary
+ // condition in TryAddAction (currentSize + actionSize > maxChunkBytes) must treat "equal"
+ // as fitting, so both actions land in the same, single, non-partial chunk.
+ P.OrchestratorAction action0 = BuildScheduleTaskAction(0, 16);
+ P.OrchestratorAction action1 = BuildScheduleTaskAction(1, 32);
+ int size0 = action0.CalculateSize();
+ int size1 = action1.CalculateSize();
+
+ P.OrchestratorResponse response = BuildResponse("instance-3", action0, action1);
+ using Fixture fixture = Fixture.Create();
+
+ // Act
+ await fixture.InvokeAsync(response, size0 + size1);
+
+ // Assert
+ fixture.Sent.Should().HaveCount(1);
+ fixture.Sent[0].Actions.Should().HaveCount(2);
+ fixture.Sent[0].Actions[0].Id.Should().Be(0);
+ fixture.Sent[0].Actions[1].Id.Should().Be(1);
+#pragma warning disable CS0612
+ fixture.Sent[0].IsPartial.Should().BeFalse();
+#pragma warning restore CS0612
+ }
+
+ [Fact]
+ public async Task ActionExceedsRemainingChunkSpaceByOneByte_IsMovedToNextChunk()
+ {
+ // Arrange - same two actions, but maxChunkBytes is one byte less than their combined size.
+ // The second action must now overflow into its own, second chunk.
+ P.OrchestratorAction action0 = BuildScheduleTaskAction(0, 16);
+ P.OrchestratorAction action1 = BuildScheduleTaskAction(1, 32);
+ int size0 = action0.CalculateSize();
+ int size1 = action1.CalculateSize();
+
+ P.OrchestratorResponse response = BuildResponse("instance-4", action0, action1);
+ using Fixture fixture = Fixture.Create();
+
+ // Act
+ await fixture.InvokeAsync(response, size0 + size1 - 1);
+
+ // Assert
+ fixture.Sent.Should().HaveCount(2);
+ fixture.Sent[0].Actions.Should().ContainSingle(a => a.Id == 0);
+ fixture.Sent[1].Actions.Should().ContainSingle(a => a.Id == 1);
+#pragma warning disable CS0612
+ fixture.Sent[0].IsPartial.Should().BeTrue();
+ fixture.Sent[0].ChunkIndex.Should().Be(0);
+ fixture.Sent[1].IsPartial.Should().BeFalse();
+ fixture.Sent[1].ChunkIndex.Should().Be(1);
+#pragma warning restore CS0612
+ }
+
+ [Fact]
+ public async Task NoLargePayloadsCapability_SingleOversizedAction_ReturnsFailedCompletionResponse()
+ {
+ // Arrange - one small action and one large action that alone exceeds maxChunkBytes. Without
+ // the LargePayloads capability, this must fail the orchestration instead of sending the
+ // oversized action.
+ P.OrchestratorAction small = BuildScheduleTaskAction(0, 16);
+ P.OrchestratorAction large = BuildScheduleTaskAction(1, 2048);
+ int largeSize = large.CalculateSize();
+ int maxChunkBytes = largeSize - 1;
+
+ P.OrchestratorResponse response = BuildResponse("instance-5", small, large);
+ response.OrchestrationTraceContext = new P.OrchestrationTraceContext { SpanID = "span-5" };
+ using Fixture fixture = Fixture.Create(largePayloads: false);
+
+ // Act
+ await fixture.InvokeAsync(response, maxChunkBytes);
+
+ // Assert
+ fixture.Sent.Should().HaveCount(1);
+ P.OrchestratorResponse failure = fixture.Sent[0];
+ failure.InstanceId.Should().Be(response.InstanceId);
+ failure.CompletionToken.Should().Be(response.CompletionToken);
+ failure.OrchestrationTraceContext.SpanID.Should().Be("span-5");
+ failure.Actions.Should().HaveCount(1);
+ P.OrchestratorAction failureAction = failure.Actions[0];
+ failureAction.CompleteOrchestration.Should().NotBeNull();
+ failureAction.CompleteOrchestration.OrchestrationStatus.Should().Be(P.OrchestrationStatus.Failed);
+ failureAction.CompleteOrchestration.FailureDetails.IsNonRetriable.Should().BeTrue();
+ failureAction.CompleteOrchestration.FailureDetails.ErrorType.Should().Be(typeof(InvalidOperationException).FullName);
+ string expectedMessage = $"A single orchestrator action of type ScheduleTask with id 1 " +
+ $"exceeds the {maxChunkBytes / 1024.0 / 1024.0:F2}MB limit: {largeSize / 1024.0 / 1024.0:F2}MB. " +
+ "Enable large-payload externalization to Azure Blob Storage to support oversized actions.";
+ failureAction.CompleteOrchestration.FailureDetails.ErrorMessage.Should().Be(expectedMessage);
+ }
+
+ [Fact]
+ public async Task NoLargePayloadsCapability_FirstActionOversized_FailsOnFirstOffender_DoesNotEvaluateLaterActions()
+ {
+ // Arrange - regression coverage for a fail-fast ordering bug: without the LargePayloads
+ // capability, validation must stop at the *first* oversized action instead of first sizing
+ // the whole response and/or every action. Action id=0 is oversized, and several later
+ // actions (ids 1-3) are ALSO individually oversized with distinguishable ids. If the
+ // algorithm regresses back to "size everything, then scan for a failure", it could still
+ // produce *a* failure, but this asserts it fails on id=0 specifically - proving iteration
+ // stopped at the very first offender rather than continuing to size/scan later actions.
+ P.OrchestratorAction action0 = BuildScheduleTaskAction(0, 2048); // oversized - first, must win
+ P.OrchestratorAction action1 = BuildScheduleTaskAction(1, 2048); // also oversized
+ P.OrchestratorAction action2 = BuildScheduleTaskAction(2, 2048); // also oversized
+ P.OrchestratorAction action3 = BuildScheduleTaskAction(3, 2048); // also oversized
+ int maxChunkBytes = action0.CalculateSize() - 1;
+
+ P.OrchestratorResponse response = BuildResponse("instance-9", action0, action1, action2, action3);
+ using Fixture fixture = Fixture.Create(largePayloads: false);
+
+ // Act
+ await fixture.InvokeAsync(response, maxChunkBytes);
+
+ // Assert - exactly one response was sent (no partial chunks for the other oversized
+ // actions), and it is a failure referencing id=0.
+ fixture.Sent.Should().HaveCount(1);
+ P.OrchestratorResponse failure = fixture.Sent[0];
+ failure.Actions.Should().HaveCount(1);
+ P.OrchestratorAction failureAction = failure.Actions[0];
+ failureAction.CompleteOrchestration.Should().NotBeNull();
+ failureAction.CompleteOrchestration.OrchestrationStatus.Should().Be(P.OrchestrationStatus.Failed);
+ failureAction.CompleteOrchestration.FailureDetails.ErrorMessage.Should().Contain("with id 0 ");
+ failureAction.CompleteOrchestration.FailureDetails.ErrorMessage.Should().NotContain("with id 1 ");
+ failureAction.CompleteOrchestration.FailureDetails.ErrorMessage.Should().NotContain("with id 2 ");
+ failureAction.CompleteOrchestration.FailureDetails.ErrorMessage.Should().NotContain("with id 3 ");
+ }
+
+ [Fact]
+ public async Task NoLargePayloadsCapability_FirstActionOversized_DoesNotSizeLaterActions()
+ {
+ // Arrange - deterministic (non-timing) proof of the same regression covered above, using two
+ // complementary test-only instrumentation hooks:
+ // 1. Processor.testActionSizedHook records the id of every action whose size is actually
+ // computed during fail-fast validation. Action id=0 is oversized; ids 1-3 are ALSO
+ // individually oversized, so a regressed implementation that sizes every action up front
+ // (or continues scanning after finding one offender) would still record ids 1-3 here even
+ // though it happens to fail on id=0 first. Asserting the hook recorded *only* id=0 proves
+ // validation returned before ever sizing later actions via the per-action path.
+ // 2. Processor.testResponseSizedHook fires when the *whole response* is sized via
+ // response.CalculateSize(). Protobuf's whole-response sizing recursively sizes every
+ // action internally, bypassing hook (1) entirely - so a regression that reintroduces
+ // whole-response sizing *before* validation (instead of only after validation succeeds)
+ // would go undetected by hook (1) alone. Asserting hook (2) is never invoked proves
+ // whole-response sizing is not reached when validation fails fast.
+ P.OrchestratorAction action0 = BuildScheduleTaskAction(0, 2048); // oversized - first, must win
+ P.OrchestratorAction action1 = BuildScheduleTaskAction(1, 2048); // also oversized
+ P.OrchestratorAction action2 = BuildScheduleTaskAction(2, 2048); // also oversized
+ P.OrchestratorAction action3 = BuildScheduleTaskAction(3, 2048); // also oversized
+ int maxChunkBytes = action0.CalculateSize() - 1;
+
+ P.OrchestratorResponse response = BuildResponse("instance-10", action0, action1, action2, action3);
+ using Fixture fixture = Fixture.Create(largePayloads: false);
+
+ List sizedActionIds = new();
+ int responseSizedCount = 0;
+ Fixture.SetActionSizedHook(sizedActionIds.Add);
+ Fixture.SetResponseSizedHook(() => responseSizedCount++);
+ try
+ {
+ // Act
+ await fixture.InvokeAsync(response, maxChunkBytes);
+ }
+ finally
+ {
+ Fixture.SetActionSizedHook(null);
+ Fixture.SetResponseSizedHook(null);
+ }
+
+ // Assert - only the first action's size was ever computed; ids 1-3 were never touched, and
+ // whole-response sizing was never reached (the fail-fast return happens before it).
+ sizedActionIds.Should().Equal(0);
+ responseSizedCount.Should().Be(0);
+ fixture.Sent.Should().HaveCount(1);
+ fixture.Sent[0].Actions[0].CompleteOrchestration.FailureDetails.ErrorMessage.Should().Contain("with id 0 ");
+ }
+
+ [Fact]
+ public async Task LargePayloadsCapability_SingleOversizedAction_IsSentInsteadOfFailing()
+ {
+ // Arrange - same oversized action, but with LargePayloads capability announced. The action
+ // must be allowed through (as its own chunk) rather than failing the orchestration.
+ P.OrchestratorAction small = BuildScheduleTaskAction(0, 16);
+ P.OrchestratorAction large = BuildScheduleTaskAction(1, 2048);
+ int largeSize = large.CalculateSize();
+ int maxChunkBytes = largeSize - 1;
+
+ P.OrchestratorResponse response = BuildResponse("instance-6", small, large);
+ using Fixture fixture = Fixture.Create(largePayloads: true);
+
+ // Act
+ await fixture.InvokeAsync(response, maxChunkBytes);
+
+ // Assert - both actions are sent (each too big to share a chunk with the other), and
+ // neither call carries a failed CompleteOrchestration action.
+ fixture.Sent.Should().HaveCountGreaterOrEqualTo(1);
+ fixture.Sent.SelectMany(r => r.Actions).Should().Contain(a => a.Id == 0);
+ fixture.Sent.SelectMany(r => r.Actions).Should().Contain(a => a.Id == 1);
+ fixture.Sent.SelectMany(r => r.Actions).Should().NotContain(a => a.OrchestratorActionTypeCase == P.OrchestratorAction.OrchestratorActionTypeOneofCase.CompleteOrchestration);
+ }
+
+ [Fact]
+ public async Task MultiChunkResponse_PreservesWireCompatibilityFieldsAcrossChunks()
+ {
+ // Arrange - three actions that must span three separate chunks, verifying InstanceId,
+ // CompletionToken, and CustomStatus repeat every chunk; OrchestrationTraceContext is only
+ // set on the first chunk; NumEventsProcessed is null on the first chunk and 0 afterward;
+ // RequiresHistory is preserved; and chunk indices/IsPartial sequence correctly.
+ P.OrchestratorAction action0 = BuildScheduleTaskAction(0, 16);
+ P.OrchestratorAction action1 = BuildScheduleTaskAction(1, 16);
+ P.OrchestratorAction action2 = BuildScheduleTaskAction(2, 16);
+ int maxChunkBytes = Math.Max(action0.CalculateSize(), Math.Max(action1.CalculateSize(), action2.CalculateSize()));
+
+ P.OrchestratorResponse response = BuildResponse("instance-7", action0, action1, action2);
+ response.CustomStatus = "custom-status";
+ response.RequiresHistory = true;
+ response.OrchestrationTraceContext = new P.OrchestrationTraceContext { SpanID = "span-7" };
+
+ using Fixture fixture = Fixture.Create();
+
+ // Act
+ await fixture.InvokeAsync(response, maxChunkBytes);
+
+ // Assert
+ fixture.Sent.Should().HaveCount(3);
+ for (int i = 0; i < fixture.Sent.Count; i++)
+ {
+ P.OrchestratorResponse chunk = fixture.Sent[i];
+ chunk.InstanceId.Should().Be("instance-7");
+ chunk.CompletionToken.Should().Be(response.CompletionToken);
+ chunk.CustomStatus.Should().Be("custom-status");
+ chunk.RequiresHistory.Should().BeTrue();
+#pragma warning disable CS0612
+ chunk.ChunkIndex.Should().Be(i);
+ chunk.IsPartial.Should().Be(i < fixture.Sent.Count - 1);
+#pragma warning restore CS0612
+
+ if (i == 0)
+ {
+ chunk.NumEventsProcessed.Should().BeNull();
+ chunk.OrchestrationTraceContext.Should().NotBeNull();
+ chunk.OrchestrationTraceContext.SpanID.Should().Be("span-7");
+ }
+ else
+ {
+ chunk.NumEventsProcessed.Should().Be(0);
+ chunk.OrchestrationTraceContext.Should().BeNull();
+ }
+ }
+
+ // All three actions were sent, in order, across the chunks, with none duplicated or dropped.
+ fixture.Sent.SelectMany(r => r.Actions).Select(a => a.Id).Should().Equal(0, 1, 2);
+ }
+
+ [Fact]
+ public async Task TransientRpcError_DuringSend_RetriesAndEventuallySucceeds()
+ {
+ // Arrange - a response that fits in a single chunk (fast path), whose first send attempt
+ // fails with a transient gRPC error. The method must still rely on ExecuteWithRetryAsync to
+ // retry the same request and eventually succeed.
+ P.OrchestratorResponse response = BuildResponse("instance-8", BuildScheduleTaskAction(0, 16));
+ int maxChunkBytes = response.CalculateSize();
+
+ using Fixture fixture = Fixture.Create(transientRetryBackoffBase: TimeSpan.FromMilliseconds(1));
+ fixture.FailNextAttempts(1);
+
+ // Act
+ await fixture.InvokeAsync(response, maxChunkBytes);
+
+ // Assert
+ fixture.AttemptCount.Should().Be(2);
+ fixture.Sent.Should().HaveCount(1);
+ fixture.Sent[0].Should().BeSameAs(response);
+ }
+
+ static P.OrchestratorResponse BuildResponse(string instanceId, params P.OrchestratorAction[] actions)
+ {
+ P.OrchestratorResponse response = new()
+ {
+ InstanceId = instanceId,
+ CompletionToken = Guid.NewGuid().ToString("N"),
+ };
+ response.Actions.AddRange(actions);
+ return response;
+ }
+
+ static P.OrchestratorAction BuildScheduleTaskAction(int id, int payloadBytes)
+ {
+ return new P.OrchestratorAction
+ {
+ Id = id,
+ ScheduleTask = new P.ScheduleTaskAction
+ {
+ Name = "Echo",
+ Input = new string('x', payloadBytes),
+ },
+ };
+ }
+
+ ///
+ /// Test fixture that constructs a real GrpcDurableTaskWorker.Processor via reflection (it
+ /// is a private nested type) wired to a strictly-mocked gRPC client, and exposes a helper to
+ /// invoke the private CompleteOrchestratorTaskWithChunkingAsync method directly.
+ ///
+ sealed class Fixture : IDisposable
+ {
+ static readonly MethodInfo Method = FindMethod();
+ static readonly FieldInfo ActionSizedHookField = FindActionSizedHookField();
+ static readonly FieldInfo ResponseSizedHookField = FindResponseSizedHookField();
+
+ readonly object processor;
+ readonly object gate = new();
+ int attemptsToFail;
+
+ Fixture(object processor, Mock clientMock)
+ {
+ this.processor = processor;
+ this.ClientMock = clientMock;
+ }
+
+ public Mock ClientMock { get; }
+
+ public List Sent { get; } = new();
+
+ public int AttemptCount { get; private set; }
+
+ ///
+ /// Sets (or clears, when passed ) the test-only static hook that
+ /// Processor.CompleteOrchestratorTaskWithChunkingAsync invokes immediately after
+ /// computing each action's size during fail-fast validation. Always reset to
+ /// after use to avoid leaking state into other tests.
+ ///
+ public static void SetActionSizedHook(Action? hook)
+ {
+ ActionSizedHookField.SetValue(null, hook);
+ }
+
+ ///
+ /// Sets (or clears, when passed ) the test-only static hook that
+ /// Processor.CompleteOrchestratorTaskWithChunkingAsync invokes immediately after
+ /// sizing the *whole response* via response.CalculateSize(). Always reset to
+ /// after use to avoid leaking state into other tests.
+ ///
+ public static void SetResponseSizedHook(Action? hook)
+ {
+ ResponseSizedHookField.SetValue(null, hook);
+ }
+
+ public static Fixture Create(
+ bool largePayloads = false,
+ TimeSpan? transientRetryBackoffBase = null)
+ {
+ GrpcDurableTaskWorkerOptions grpcOptionsValue = new();
+ if (largePayloads)
+ {
+ grpcOptionsValue.Capabilities.Add(P.WorkerCapability.LargePayloads);
+ }
+
+ if (transientRetryBackoffBase.HasValue)
+ {
+ grpcOptionsValue.Internal.TransientRetryBackoffBase = transientRetryBackoffBase.Value;
+ }
+
+ OptionsMonitorStub grpcOptions = new(grpcOptionsValue);
+ OptionsMonitorStub workerOptions = new(new DurableTaskWorkerOptions());
+ Mock factoryMock = new(MockBehavior.Strict);
+
+ GrpcDurableTaskWorker worker = new(
+ name: "Test",
+ factory: factoryMock.Object,
+ grpcOptions: grpcOptions,
+ workerOptions: workerOptions,
+ services: Mock.Of(),
+ loggerFactory: NullLoggerFactory.Instance,
+ orchestrationFilter: null,
+ exceptionPropertiesProvider: null);
+
+ CallInvoker callInvoker = Mock.Of();
+ Mock clientMock = new(
+ MockBehavior.Strict, new object[] { callInvoker });
+
+ Type processorType = typeof(GrpcDurableTaskWorker).GetNestedType("Processor", BindingFlags.NonPublic)!;
+ object processorInstance = Activator.CreateInstance(
+ processorType,
+ BindingFlags.Public | BindingFlags.Instance,
+ binder: null,
+ args: new object?[] { worker, clientMock.Object, null, null },
+ culture: null)!;
+
+ Fixture fixture = new(processorInstance, clientMock);
+
+ clientMock
+ .Setup(c => c.CompleteOrchestratorTaskAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns((P.OrchestratorResponse r, Metadata h, DateTime? d, CancellationToken ct) =>
+ fixture.HandleSend(r));
+
+ return fixture;
+ }
+
+ ///
+ /// Configures the next send attempts (across all chunks) to fail
+ /// with a transient (Unavailable) gRPC error before subsequent attempts succeed.
+ ///
+ public void FailNextAttempts(int count)
+ {
+ lock (this.gate)
+ {
+ this.attemptsToFail = count;
+ }
+ }
+
+ public Task InvokeAsync(P.OrchestratorResponse response, int maxChunkBytes, CancellationToken cancellationToken = default)
+ {
+ return (Task)Method.Invoke(this.processor, new object?[] { response, maxChunkBytes, cancellationToken })!;
+ }
+
+ public void Dispose()
+ {
+ }
+
+ AsyncUnaryCall HandleSend(P.OrchestratorResponse response)
+ {
+ bool shouldFail;
+ lock (this.gate)
+ {
+ this.AttemptCount++;
+ shouldFail = this.attemptsToFail > 0;
+ if (shouldFail)
+ {
+ this.attemptsToFail--;
+ }
+ }
+
+ if (shouldFail)
+ {
+ return RpcExceptionAsyncUnaryCall(StatusCode.Unavailable);
+ }
+
+ this.Sent.Add(response);
+ return CompletedAsyncUnaryCall(new P.CompleteTaskResponse());
+ }
+
+ static MethodInfo FindMethod()
+ {
+ Type processorType = typeof(GrpcDurableTaskWorker).GetNestedType("Processor", BindingFlags.NonPublic)!;
+ return processorType.GetMethod("CompleteOrchestratorTaskWithChunkingAsync", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ }
+
+ static FieldInfo FindActionSizedHookField()
+ {
+ Type processorType = typeof(GrpcDurableTaskWorker).GetNestedType("Processor", BindingFlags.NonPublic)!;
+ return processorType.GetField("testActionSizedHook", BindingFlags.Static | BindingFlags.NonPublic)!;
+ }
+
+ static FieldInfo FindResponseSizedHookField()
+ {
+ Type processorType = typeof(GrpcDurableTaskWorker).GetNestedType("Processor", BindingFlags.NonPublic)!;
+ return processorType.GetField("testResponseSizedHook", BindingFlags.Static | BindingFlags.NonPublic)!;
+ }
+
+ static AsyncUnaryCall CompletedAsyncUnaryCall(T response)
+ {
+ Task respTask = Task.FromResult(response);
+ return new AsyncUnaryCall(
+ respTask,
+ Task.FromResult(new Metadata()),
+ () => new Status(StatusCode.OK, string.Empty),
+ () => new Metadata(),
+ () => { });
+ }
+
+ static AsyncUnaryCall RpcExceptionAsyncUnaryCall(StatusCode statusCode, string detail = "transient error")
+ {
+ RpcException ex = new(new Status(statusCode, detail));
+ Task respTask = Task.FromException(ex);
+ return new AsyncUnaryCall(
+ respTask,
+ Task.FromResult(new Metadata()),
+ () => new Status(statusCode, detail),
+ () => new Metadata(),
+ () => { });
+ }
+
+ sealed class OptionsMonitorStub : IOptionsMonitor
+ where T : class, new()
+ {
+ readonly T value;
+
+ public OptionsMonitorStub(T value) => this.value = value;
+
+ public T CurrentValue => this.value;
+
+ public T Get(string? name) => this.value;
+
+ public IDisposable OnChange(Action listener) => NullDisposable.Instance;
+
+ sealed class NullDisposable : IDisposable
+ {
+ public static readonly NullDisposable Instance = new();
+
+ public void Dispose()
+ {
+ }
+ }
+ }
+ }
+}