Performance: remove unnecessary Task.Run from payload unary interception - #781
Open
berndverst wants to merge 5 commits into
Open
Performance: remove unnecessary Task.Run from payload unary interception#781berndverst wants to merge 5 commits into
berndverst wants to merge 5 commits into
Conversation
Fixes #774 Replace the Task.Run(async () => {...}) wrapper around request externalization + continuation invocation with a directly-invoked local async function. This avoids an unconditional thread-pool hop on every unary call when externalization completes synchronously (the common case), while preserving identical externalization ordering, exception propagation, cancellation, response headers, status/trailers, and disposal behavior. Add focused unit tests in Worker.Grpc.Tests covering the success path (ordering + response/headers/status/trailers), externalization failure, cancellation, and disposal (before completion and after failure), plus a regression test asserting externalization and the continuation run inline on the calling thread instead of being dispatched to the thread pool. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42263041-0f76-4a63-baa9-e8fa73c35466
Contributor
There was a problem hiding this comment.
Pull request overview
This PR removes an unconditional Task.Run from the Azure Blob payload interceptor’s unary call path to eliminate a thread-pool hop on every gRPC unary call, while preserving the ordering and error/cancellation behavior of request externalization and continuation invocation.
Changes:
- Replaced
Task.Run(async () => ...)with a directly-invoked local async function (StartCallAsync) inPayloadInterceptor.AsyncUnaryCall. - Added unit tests validating ordering, exception/cancellation propagation, disposal behavior, and a regression guard ensuring synchronous externalization runs inline (no thread hop).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/Extensions/AzureBlobPayloads/Interceptors/PayloadInterceptor.cs | Removes the Task.Run wrapper and starts the async externalization/continuation sequence inline to avoid a forced thread-pool dispatch. |
| test/Worker/Grpc.Tests/PayloadInterceptorTests.cs | Adds coverage for success/failure/cancellation/disposal flows and includes a regression test to ensure the call path remains inline when externalization is synchronous. |
Dispose() schedules the inner call's disposal via a ContinueWith on startCallTask, which is a separate continuation from the one driving ResponseAsync(). The two continuations are not ordered relative to each other, so asserting disposeInvoked immediately after awaiting ResponseAsync could race and fail intermittently. Signal a TaskCompletionSource from the fake call's dispose action and await it (with a bounded 5s timeout) before asserting, so the test deterministically waits for Dispose's continuation instead of relying on incidental ordering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42263041-0f76-4a63-baa9-e8fa73c35466
- PayloadInterceptor.AsyncUnaryCall's local functions (StartCallAsync, ResponseAsync, ResponseHeadersAsync) now use ConfigureAwait(false) on every await. Since Task.Run no longer isolates this chain onto a context-free thread-pool thread, continuations could otherwise capture and resume on the caller's SynchronizationContext, risking deadlocks in contexts like legacy ASP.NET or WPF/WinForms UI threads. - Fixed AsyncUnaryCall_Success_ExternalizesThenInvokesContinuationThenResolves which declared the call with a using declaration while also calling Dispose() explicitly, causing Dispose() to run twice. Removed the using declaration so disposal happens exactly once via the explicit call, matching the existing assertion. - Streaming path (AsyncServerStreamingCall/TransformingStreamReader) and the Maybe* externalize/resolve helpers are intentionally left unchanged (out of scope for this fix). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42263041-0f76-4a63-baa9-e8fa73c35466
Contributor
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/Extensions/AzureBlobPayloads/Interceptors/PayloadInterceptor.cs:49
StartCallAsyncnow runsExternalizeRequestPayloadsAsyncon the caller's context. WhileConfigureAwait(false)here avoids resuming this method on the caller's SynchronizationContext, it does not prevent anyawaitinsideExternalizeRequestPayloadsAsync(or helpers likeMaybeExternalizeAsync/MaybeResolveAsync) from capturing that context. Previously, theTask.Runwrapper ensured these awaits ran without a SynchronizationContext. If preserving that behavior is important, consider applyingConfigureAwait(false)to awaits inside the externalization/resolve code paths as well (or otherwise ensuring they run onTaskScheduler.Default).
// Externalize first; if this fails, do not proceed to send the gRPC call. This now runs
// inline on the caller's thread rather than on a dedicated thread-pool thread (via
// Task.Run), so ConfigureAwait(false) is required here to avoid capturing and resuming
// on the caller's SynchronizationContext/TaskScheduler, matching library-safe behavior.
await this.ExternalizeRequestPayloadsAsync(request, context.Options.CancellationToken)
.ConfigureAwait(false);
YunchuWang
previously approved these changes
Jul 27, 2026
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 01dc5b7c-c742-45b7-ae8c-5cda9bcfbd2c
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #774
Summary
PayloadInterceptor.AsyncUnaryCallunconditionally wrapped request-payload externalization + continuation invocation inTask.Run(async () => {...}), forcing a thread-pool hop on every unary gRPC call even when externalization completes synchronously (the common case, since most requests don't exceed the large-payload threshold).This PR replaces the
Task.Runwrapper with a directly-invoked local async function (StartCallAsync()), removing the thread-pool dispatch while keeping the exact same internal sequence: externalize first, then invoke the continuation only if externalization succeeds.Why behavior is unchanged
OperationCanceledException→ faulted/canceled task) are identical whether the async method is invoked viaTask.Runor called directly — only the scheduling (inline vs. thread-pool) differs, not the resultingTaskstate.ResponseAsync,ResponseHeadersAsync,GetStatus,GetTrailers, andDisposeall continue to read from the samestartCallTask, untouched by this change.Tests
Added
test/Worker/Grpc.Tests/PayloadInterceptorTests.cs(home chosen because this project already has a conditionalProjectReferencetoAzureBlobPayloadsfor compatible target frameworks) covering:ResponseAsync, status reflects the failure.OperationCanceledExceptionpropagates, status isCancelled.Dispose()called before the inner call exists is safely deferred and applied once available.Dispose()is a no-op, does not throw.Task.Run-based implementation, confirming it's a meaningful regression guard).All 143 tests in
Worker.Grpc.Testspass locally.