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() + { + } + } + } + } +}