Perf: avoid repeated protobuf sizing during orchestration response chunking - #784
Perf: avoid repeated protobuf sizing during orchestration response chunking#784berndverst wants to merge 6 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. |
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
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)
{
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
…-repeated-protobuf-sizing
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 01dc5b7c-c742-45b7-ae8c-5cda9bcfbd2c
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 (1)
src/Worker/Grpc/GrpcDurableTaskWorker.Processor.cs:1156
- In the
!largePayloadspath, per-action sizing/validation runs unconditionally beforeresponse.CalculateSize(). That means even when the response ends up fitting in one chunk, each action is sized once during validation and then sized again as part of whole-response sizing, which undermines the stated perf goal of avoiding redundant protobuf sizing on the direct-send path.
If the intent is to avoid per-action sizing when the response fits, consider calculating totalSize first and returning early when totalSize <= maxChunkBytes (skipping validation entirely in that case, since an oversized single action cannot exist when the total fits). If the current ordering is intentional to preserve fail-fast behavior for oversized actions (avoid whole-response sizing before rejecting), the PR description/tests should be aligned to that tradeoff.
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)
{
actionSizes = new int[response.Actions.Count];
for (int i = 0; i < response.Actions.Count; i++)
{
Summary
Reduces redundant protobuf sizing in
CompleteOrchestratorTaskWithChunkingAsync, especially when a response must be split into chunks.What changed
LargePayloadscapability, action validation intentionally remains first and fail-fast: sizing stops at the first oversized action, before whole-response sizing or later-action work.CalculateSize()on each action again.LargePayloads, the response is sized first; per-action sizes are computed only if chunking is required.TryAddActionreceives a cached action size, and chunk packing indexes the protobufRepeatedFielddirectly without aToList()copy.A direct non-
LargePayloadssend still performs per-action validation followed by whole-response sizing. That extra traversal is deliberate to preserve the existing first-offender/fail-fast behavior. The eliminated redundant pass is the later per-action sizing previously performed during chunk packing.Preserved behavior
LargePayloadsis unavailable.LargePayloadsis enabled.Tests
Added 10 focused tests covering direct sends, exact and one-byte-over boundaries, fail-fast first-offender behavior, deterministic sizing hooks,
LargePayloadsbehavior, multi-chunk wire compatibility, and retry. Static sizing hooks are isolated in a non-parallel xUnit collection.The full
Worker.Grpc.Testssuite and dependent integration-project build pass.Fixes #773