Perf: avoid repeated protobuf sizing during orchestration response chunking#784
Perf: avoid repeated protobuf sizing during orchestration response chunking#784berndverst wants to merge 4 commits into
Conversation
…unking CompleteOrchestratorTaskWithChunkingAsync previously called CalculateSize() on every action up to three times: once during oversized-action validation, once implicitly via response.CalculateSize() for the whole-response check, and once more per action inside TryAddAction while packing chunks. Now the whole response is sized exactly once up front. If it fits within maxChunkBytes, it is sent directly with no per-action validation or sizing pass, since a fitting total guarantees no individual action exceeds the limit. Only when chunking is actually required are individual action sizes computed, and those cached sizes are reused for both oversized-action validation and chunk packing (TryAddAction now takes a precomputed size instead of recalculating it). Wire compatibility, size-boundary behavior, LargePayloads capability behavior, oversized-single-action failure behavior, retry behavior, and public API are all unchanged. Adds CompleteOrchestratorTaskWithChunkingTests.cs with focused tests for: direct-send fast path, response/action size boundaries, LargePayloads on/off with an oversized action, multi-chunk wire-compatibility field preservation, and retry-then-success behavior. Fixes #773 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc31b150-63de-43df-9710-417064c8b07c
There was a problem hiding this comment.
Pull request overview
Optimizes the gRPC worker’s orchestration completion chunking path to reduce CPU overhead by eliminating repeated protobuf sizing work, while preserving existing chunking wire-compatibility and capability behaviors.
Changes:
- Computes total orchestrator response size once and short-circuits to a direct send when it fits.
- When chunking is required, precomputes per-action sizes once and reuses them for both oversized-action validation (when
LargePayloadsis absent) and chunk packing. - Adds focused unit tests covering size boundaries,
LargePayloadsbehavior, wire-compatibility field rules across chunks, and retry behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| test/Worker/Grpc.Tests/CompleteOrchestratorTaskWithChunkingTests.cs | Adds targeted unit tests for sizing/chunking boundaries, capability behavior, wire-compat fields, and retry semantics. |
| src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs | Refactors chunking logic to avoid repeated protobuf CalculateSize() calls by using a total-size fast path and cached per-action sizes when chunking. |
| // Helper to add an action to the current chunk if it fits, using a precomputed action size | ||
| // so we never need to call CalculateSize() on the same action more than once. |
| // Response is too large to fit in a single chunk. Compute each action's serialized size | ||
| // exactly once here, and reuse the cached values below for both oversized-action | ||
| // validation and chunk packing. | ||
| List<P.OrchestratorAction> allActions = response.Actions.ToList(); | ||
| int[] actionSizes = new int[allActions.Count]; | ||
| for (int i = 0; i < allActions.Count; i++) | ||
| { | ||
| actionSizes[i] = allActions[i].CalculateSize(); | ||
| } |
Restore fail-fast behavior for the no-LargePayloads case: validate each action's size in order and return immediately on the first action exceeding maxChunkBytes, without sizing later actions or computing the whole-response size first. This matches the original (pre-optimization) per-action-validation-first ordering while still reusing the sizes computed during validation for chunk packing afterward, so the issue #773 optimization is preserved for all non-failure cases (LargePayloads enabled, or response fits in one chunk, or chunking succeeds). Adds two regression tests: - Asserts the failure references the first oversized action (id=0) even when several later actions are also individually oversized, proving iteration stops at the first offender. - A calibrated timing-based test using a large shared string across 200 later actions (~1000ms to size all vs ~4ms for one) asserting the fail-fast path completes in a small fraction of that time. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc31b150-63de-43df-9710-417064c8b07c
|
Addressed the fail-fast regression flagged in review (commit e68bbe0). Root cause: the previous version of this PR called Fix: when the New tests added:
Validation: full |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs:1126
- When LargePayloads is not enabled, this validation loop calls
action.CalculateSize()for every action even if the response ultimately fits in a single chunk, and then the fast-path still callsresponse.CalculateSize()(which re-walks all actions). That contradicts the PR description’s “size once, then direct-send with no per-action sizing” claim and limits the perf win on the common fit-in-one-chunk path. Consider either (a) updating the PR description to reflect this intentional fail-fast validation behavior, or (b) restructuring the method so the single-chunk fast path runs before the per-action sizing loop (accepting the tradeoff that you’d lose the current fail-fast behavior for oversized actions).
// 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
| string bigSharedInput = new('x', 40_000_000); | ||
| P.OrchestratorAction action0 = BuildScheduleTaskAction(0, 16); // small, oversized only relative to maxChunkBytes below | ||
| int maxChunkBytes = action0.CalculateSize() - 1; | ||
|
|
||
| List<P.OrchestratorAction> actions = new() { action0 }; |
The previous fail-fast regression test allocated a large shared string and asserted a wall-clock threshold, which can flake under GC/CI contention. Add a minimal test-only hook invoked immediately after each action's size is computed in the no-LargePayloads validation loop (always null in production, zero overhead beyond a null check), and rewrite the regression test to use it: it now asserts deterministically that only the first oversized action's id was ever sized, even when several later actions are also individually oversized. No wall-clock or giant-allocation assertions remain. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc31b150-63de-43df-9710-417064c8b07c
Update: replaced flaky timing test with a deterministic oneAddressed the follow-up review finding that the fail-fast regression test (NoLargePayloadsCapability_FirstActionOversized_ShortCircuitsBeforeSizingLaterActions) relied on wall-clock timing (500ms threshold) plus a large (40MB) allocation, which could flake under GC/CI contention. Change (commit c91c4a7):
Validation:
Fixes #773 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs:1141
- The new implementation still sizes/validates every action up front when the LargePayloads capability is not present (before the whole-response size check). This contradicts the PR description’s stated fast path (“if the response fits, send directly with no per-action validation/sizing”) and means the perf win won’t apply to the common non-chunked, no-LargePayloads case unless the behavior or the description/tests are adjusted.
// 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)
{
| public class CompleteOrchestratorTaskWithChunkingTests | ||
| { |
Adds a second test-only hook (testResponseSizedHook) invoked at the response.CalculateSize() call site, complementing the existing per-action sizing hook. The per-action hook alone cannot detect a regression that reintroduces whole-response sizing before fail-fast validation, since protobuf's recursive sizing bypasses it. The fail-fast test now asserts the whole-response hook is never invoked, and a new assertion on the happy-path test proves the hook fires exactly once, guarding against false-positive coverage from broken hook wiring. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc31b150-63de-43df-9710-417064c8b07c
|
Round 3 fix (commit 63cf103): closed the whole-response-sizing coverage gap. Issue: The round-2 hook ( Fix:
Validation:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs:1205
- The comment above the total-size fast path says it sends the response "without any further per-action work", but when LargePayloads is not enabled the method already did a full per-action CalculateSize() validation loop before reaching this point. Either the comment should be clarified, or the code should be reordered to actually skip per-action sizing when the 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();
src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs:1151
- The PR description states the whole-response size is computed up front and that if it fits within maxChunkBytes the response is sent with no per-action sizing/validation pass. However, for workers without the LargePayloads capability this implementation still sizes every action (Validate loop) before computing totalSize, even when the response ultimately fits in a single chunk. Consider reordering to: (1) compute totalSize first and return early if it fits; (2) only if chunking is required, compute per-action sizes once for validation and packing (or update the PR description/tests if the current fail-fast-before-total-sizing behavior is intentional).
// 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;
test/Worker/Grpc.Tests/CompleteOrchestratorTaskWithChunkingTests.cs:23
- These tests mutate global static hooks (via reflection) to observe sizing behavior. Because xUnit runs test classes in parallel by default, any other test exercising the same Processor method concurrently could observe or trigger these hooks, making the assertions flaky. Consider putting this class into a non-parallelized test collection to isolate the static hook state.
public class CompleteOrchestratorTaskWithChunkingTests
Summary
CompleteOrchestratorTaskWithChunkingAsyncinsrc/Worker/Grpc/GrpcDurableTaskWorker.Processor.cspreviously calledCalculateSize()on every orchestrator action up to three times per response:ValidateActionsSize), whenLargePayloadswas not enabled.response.CalculateSize()for the whole-response fits-in-one-chunk check (this recursively callsCalculateSize()on every action again).TryAddActionwhile packing chunks.For large action payloads and/or many actions, this repeated protobuf sizing added unnecessary CPU overhead.
Fix
response.CalculateSize()). If it fits withinmaxChunkBytes, it is sent directly with no per-action validation or sizing pass — a fitting total size guarantees no individual action can exceed the same limit (every field, including each action, contributes a non-negative amount to the total).int[]cache — and those cached sizes are reused for both:LargePayloadscapability is not present), andTryAddActionchunk-packing loop (which now takes a precomputedint actionSizeparameter instead of callingCalculateSize()internally).What's preserved (unchanged)
IsPartial/ChunkIndex(deprecated but still emitted),NumEventsProcessednull-on-chunk-0/0-afterward,OrchestrationTraceContextonly on chunk 0,CustomStatus/InstanceId/CompletionToken/RequiresHistoryrepeated every chunk.LargePayloadscapability behavior: oversized actions are still allowed through un-failed when the capability is present, and still fail the orchestration (same error message format) when it is not.TaskFailureDetails/error message construction.ExecuteWithRetryAsyncexactly as before.Tests
Added
test/Worker/Grpc.Tests/CompleteOrchestratorTaskWithChunkingTests.cswith 8 focused tests covering:LargePayloadsoff vs. on with a single oversized action (failure vs. pass-through).Validation
dotnet build src\Worker\Grpc\Worker.Grpc.csproj -c Debug— 0 errors.dotnet test test\Worker\Grpc.Tests\Worker.Grpc.Tests.csproj— full suite: 145/145 passed (including the 8 new tests).dotnet build test\Grpc.IntegrationTests\Grpc.IntegrationTests.csproj -c Debug— 0 errors (confirms no signature-dependent breakage for integration tests).Fixes #773