|
2 | 2 | // Licensed under the MIT License. |
3 | 3 |
|
4 | 4 | using System.Diagnostics; |
| 5 | +using System.Linq; |
5 | 6 | using System.Text; |
6 | 7 | using DurableTask.Core; |
7 | 8 | using DurableTask.Core.Entities; |
8 | 9 | using DurableTask.Core.Entities.OperationFormat; |
9 | 10 | using DurableTask.Core.History; |
| 11 | +using Google.Protobuf; |
10 | 12 | using Microsoft.DurableTask.Abstractions; |
11 | 13 | using Microsoft.DurableTask.Entities; |
12 | 14 | using Microsoft.DurableTask.Tracing; |
@@ -755,7 +757,10 @@ await this.client.AbandonTaskOrchestratorWorkItemAsync( |
755 | 757 | response.Actions.Count, |
756 | 758 | GetActionsListForLogging(response.Actions)); |
757 | 759 |
|
758 | | - await this.client.CompleteOrchestratorTaskAsync(response, cancellationToken: cancellationToken); |
| 760 | + await this.CompleteOrchestratorTaskWithChunkingAsync( |
| 761 | + response, |
| 762 | + this.worker.grpcOptions.CompleteOrchestrationWorkItemChunkSizeInBytes, |
| 763 | + cancellationToken); |
759 | 764 | } |
760 | 765 |
|
761 | 766 | async Task OnRunActivityAsync(P.ActivityRequest request, string completionToken, CancellationToken cancellation) |
@@ -914,5 +919,139 @@ async Task OnRunEntityBatchAsync( |
914 | 919 |
|
915 | 920 | await this.client.CompleteEntityTaskAsync(response, cancellationToken: cancellation); |
916 | 921 | } |
| 922 | + |
| 923 | + /// <summary> |
| 924 | + /// Completes an orchestration task with automatic chunking if the response exceeds the maximum size. |
| 925 | + /// </summary> |
| 926 | + /// <param name="response">The orchestrator response to send.</param> |
| 927 | + /// <param name="maxChunkBytes">The maximum size in bytes for each chunk.</param> |
| 928 | + /// <param name="cancellationToken">The cancellation token.</param> |
| 929 | + async Task CompleteOrchestratorTaskWithChunkingAsync( |
| 930 | + P.OrchestratorResponse response, |
| 931 | + int maxChunkBytes, |
| 932 | + CancellationToken cancellationToken) |
| 933 | + { |
| 934 | + // Validate that no single action exceeds the maximum chunk size |
| 935 | + static P.TaskFailureDetails? ValidateActionsSize(IEnumerable<P.OrchestratorAction> actions, int maxChunkBytes) |
| 936 | + { |
| 937 | + foreach (P.OrchestratorAction action in actions) |
| 938 | + { |
| 939 | + int actionSize = action.CalculateSize(); |
| 940 | + if (actionSize > maxChunkBytes) |
| 941 | + { |
| 942 | + // TODO: large payload doc is not available yet on aka.ms, add doc link to below error message |
| 943 | + string errorMessage = $"A single orchestrator action of type {action.OrchestratorActionTypeCase} with id {action.Id} " + |
| 944 | + $"exceeds the {maxChunkBytes / 1024.0 / 1024.0:F2}MB limit: {actionSize / 1024.0 / 1024.0:F2}MB. " + |
| 945 | + "Enable large-payload externalization to Azure Blob Storage to support oversized actions."; |
| 946 | + return new P.TaskFailureDetails |
| 947 | + { |
| 948 | + ErrorType = typeof(InvalidOperationException).FullName, |
| 949 | + ErrorMessage = errorMessage, |
| 950 | + IsNonRetriable = true, |
| 951 | + }; |
| 952 | + } |
| 953 | + } |
| 954 | + |
| 955 | + return null; |
| 956 | + } |
| 957 | + |
| 958 | + P.TaskFailureDetails? validationFailure = ValidateActionsSize(response.Actions, maxChunkBytes); |
| 959 | + if (validationFailure != null) |
| 960 | + { |
| 961 | + // Complete the orchestration with a failed status and failure details |
| 962 | + P.OrchestratorResponse failureResponse = new() |
| 963 | + { |
| 964 | + InstanceId = response.InstanceId, |
| 965 | + CompletionToken = response.CompletionToken, |
| 966 | + OrchestrationTraceContext = response.OrchestrationTraceContext, |
| 967 | + Actions = |
| 968 | + { |
| 969 | + new P.OrchestratorAction |
| 970 | + { |
| 971 | + CompleteOrchestration = new P.CompleteOrchestrationAction |
| 972 | + { |
| 973 | + OrchestrationStatus = P.OrchestrationStatus.Failed, |
| 974 | + FailureDetails = validationFailure, |
| 975 | + }, |
| 976 | + }, |
| 977 | + }, |
| 978 | + }; |
| 979 | + |
| 980 | + await this.client.CompleteOrchestratorTaskAsync(failureResponse, cancellationToken: cancellationToken); |
| 981 | + return; |
| 982 | + } |
| 983 | + |
| 984 | + // Helper to add an action to the current chunk if it fits |
| 985 | + static bool TryAddAction( |
| 986 | + Google.Protobuf.Collections.RepeatedField<P.OrchestratorAction> dest, |
| 987 | + P.OrchestratorAction action, |
| 988 | + ref int currentSize, |
| 989 | + int maxChunkBytes) |
| 990 | + { |
| 991 | + int actionSize = action.CalculateSize(); |
| 992 | + if (currentSize + actionSize > maxChunkBytes) |
| 993 | + { |
| 994 | + return false; |
| 995 | + } |
| 996 | + |
| 997 | + dest.Add(action); |
| 998 | + currentSize += actionSize; |
| 999 | + return true; |
| 1000 | + } |
| 1001 | + |
| 1002 | + // Check if the entire response fits in one chunk |
| 1003 | + int totalSize = response.CalculateSize(); |
| 1004 | + if (totalSize <= maxChunkBytes) |
| 1005 | + { |
| 1006 | + // Response fits in one chunk, send it directly (isPartial defaults to false) |
| 1007 | + await this.client.CompleteOrchestratorTaskAsync(response, cancellationToken: cancellationToken); |
| 1008 | + return; |
| 1009 | + } |
| 1010 | + |
| 1011 | + // Response is too large, split into multiple chunks |
| 1012 | + int actionsCompletedSoFar = 0, chunkIndex = 0; |
| 1013 | + List<P.OrchestratorAction> allActions = response.Actions.ToList(); |
| 1014 | + bool isPartial = true; |
| 1015 | + |
| 1016 | + while (isPartial) |
| 1017 | + { |
| 1018 | + P.OrchestratorResponse chunkedResponse = new() |
| 1019 | + { |
| 1020 | + InstanceId = response.InstanceId, |
| 1021 | + CustomStatus = response.CustomStatus, |
| 1022 | + CompletionToken = response.CompletionToken, |
| 1023 | + RequiresHistory = response.RequiresHistory, |
| 1024 | + NumEventsProcessed = 0, |
| 1025 | + ChunkIndex = chunkIndex, |
| 1026 | + }; |
| 1027 | + |
| 1028 | + int chunkPayloadSize = 0; |
| 1029 | + |
| 1030 | + // Fill the chunk with actions until we reach the size limit |
| 1031 | + while (actionsCompletedSoFar < allActions.Count && |
| 1032 | + TryAddAction(chunkedResponse.Actions, allActions[actionsCompletedSoFar], ref chunkPayloadSize, maxChunkBytes)) |
| 1033 | + { |
| 1034 | + actionsCompletedSoFar++; |
| 1035 | + } |
| 1036 | + |
| 1037 | + // Determine if this is a partial chunk (more actions remaining) |
| 1038 | + isPartial = actionsCompletedSoFar < allActions.Count; |
| 1039 | + chunkedResponse.IsPartial = isPartial; |
| 1040 | + |
| 1041 | + if (chunkIndex == 0) |
| 1042 | + { |
| 1043 | + // The first chunk preserves the original response's NumEventsProcessed value (null) |
| 1044 | + // When this is set to null, backend by default handles all the messages in the workitem. |
| 1045 | + // For subsequent chunks, we set it to 0 since all messages are already handled in first chunk. |
| 1046 | + chunkedResponse.NumEventsProcessed = null; |
| 1047 | + chunkedResponse.OrchestrationTraceContext = response.OrchestrationTraceContext; |
| 1048 | + } |
| 1049 | + |
| 1050 | + chunkIndex++; |
| 1051 | + |
| 1052 | + // Send the chunk |
| 1053 | + await this.client.CompleteOrchestratorTaskAsync(chunkedResponse, cancellationToken: cancellationToken); |
| 1054 | + } |
| 1055 | + } |
917 | 1056 | } |
918 | 1057 | } |
0 commit comments