Skip to content

Performance: remove unnecessary Task.Run from payload unary interception#781

Open
berndverst wants to merge 2 commits into
mainfrom
berndverst-cuddly-disco
Open

Performance: remove unnecessary Task.Run from payload unary interception#781
berndverst wants to merge 2 commits into
mainfrom
berndverst-cuddly-disco

Conversation

@berndverst

Copy link
Copy Markdown
Member

Fixes #774

Summary

PayloadInterceptor.AsyncUnaryCall unconditionally wrapped request-payload externalization + continuation invocation in Task.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.Run wrapper 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

  • Externalization ordering: still externalize-then-continuation, unchanged.
  • Exceptions/cancellation: async state-machine semantics (including OperationCanceledException → faulted/canceled task) are identical whether the async method is invoked via Task.Run or called directly — only the scheduling (inline vs. thread-pool) differs, not the resulting Task state.
  • Response headers / status / trailers / disposal: ResponseAsync, ResponseHeadersAsync, GetStatus, GetTrailers, and Dispose all continue to read from the same startCallTask, untouched by this change.
  • Public API: no signature or visibility changes.

Tests

Added test/Worker/Grpc.Tests/PayloadInterceptorTests.cs (home chosen because this project already has a conditional ProjectReference to AzureBlobPayloads for compatible target frameworks) covering:

  • Success path: externalize → continuation → resolve ordering, response payload, headers, status, trailers.
  • Externalization failure: continuation is skipped, original exception propagates via ResponseAsync, status reflects the failure.
  • Cancellation: pre-cancelled token short-circuits externalization, continuation is skipped, OperationCanceledException propagates, status is Cancelled.
  • Disposal before completion: Dispose() called before the inner call exists is safely deferred and applied once available.
  • Disposal after externalization failure: Dispose() is a no-op, does not throw.
  • Regression test proving externalization and the continuation now run inline on the calling thread rather than being dispatched to the thread pool (verified this test fails against the prior Task.Run-based implementation, confirming it's a meaningful regression guard).

All 143 tests in Worker.Grpc.Tests pass locally.

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
Copilot AI review requested due to automatic review settings July 24, 2026 22:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) in PayloadInterceptor.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.

Comment on lines +180 to +185
externalizeGate.SetResult();
await call.ResponseAsync;

// Assert: once the inner call exists, disposal is applied to it.
disposeInvoked.Should().BeTrue();
}
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
Copilot AI review requested due to automatic review settings July 24, 2026 23:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment on lines 44 to 46
// Externalize first; if this fails, do not proceed to send the gRPC call
await this.ExternalizeRequestPayloadsAsync(request, context.Options.CancellationToken);

Comment on lines +79 to +98
using AsyncUnaryCall<string> call = interceptor.AsyncUnaryCall(
"request",
context,
(req, ctx) =>
{
order.Add("continuation");
return CreateFakeCall("response", disposeAction: () => disposeInvoked = true);
});
string response = await call.ResponseAsync;
Metadata headers = await call.ResponseHeadersAsync;

// Assert: ordering, response payload, and pass-through metadata are all preserved.
order.Should().Equal("externalize", "continuation", "resolve");
response.Should().Be("response");
headers.Should().NotBeNull();
call.GetStatus().StatusCode.Should().Be(StatusCode.OK);
call.GetTrailers().Should().NotBeNull();

call.Dispose();
disposeInvoked.Should().BeTrue();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance: remove the unnecessary Task.Run from payload unary interception

2 participants