Skip to content

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

Description

@berndverst

1. What is the issue?

The Azure Blob payload interceptor wraps request externalization and continuation startup in Task.Run for every unary gRPC call.

2. Likely priority

Medium for applications that enable externalized payloads and issue many unary client or worker gRPC calls.

3. Impact on performance

Even when all payload fields are below the externalization threshold, each unary call queues and dequeues work on the thread pool. This adds scheduling latency and pressure to a common request path without performing useful parallel work.

4. Details and code reference

Relevant interceptor implementation:

// Unary: externalize on request, resolve on response
/// <inheritdoc/>
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
{
// Build the underlying call lazily after async externalization
Task<AsyncUnaryCall<TResponse>> startCallTask = Task.Run(async () =>
{
// Externalize first; if this fails, do not proceed to send the gRPC call
await this.ExternalizeRequestPayloadsAsync(request, context.Options.CancellationToken);
// Only if externalization succeeds, proceed with the continuation
return continuation(request, context);
});
async Task<TResponse> ResponseAsync()
{
AsyncUnaryCall<TResponse> innerCall = await startCallTask;
TResponse response = await innerCall.ResponseAsync;
await this.ResolveResponsePayloadsAsync(response, context.Options.CancellationToken);
return response;
}
async Task<Metadata> ResponseHeadersAsync()
{
AsyncUnaryCall<TResponse> innerCall = await startCallTask;
return await innerCall.ResponseHeadersAsync;
}
Status GetStatus()
{
if (startCallTask.IsCanceled)
{
return new Status(StatusCode.Cancelled, "Call was cancelled.");
}
if (startCallTask.IsFaulted)
{
return new Status(StatusCode.Internal, startCallTask.Exception?.Message ?? "Unknown error");
}
if (startCallTask.Status == TaskStatus.RanToCompletion)
{
return startCallTask.Result.GetStatus();
}
// Not started yet; unknown
return new Status(StatusCode.Unknown, string.Empty);
}
Metadata GetTrailers()
{
return startCallTask.Status == TaskStatus.RanToCompletion ? startCallTask.Result.GetTrailers() : [];
}
void Dispose()
{
_ = startCallTask.ContinueWith(
t =>
{
if (t.Status == TaskStatus.RanToCompletion)
{
t.Result.Dispose();
}
},
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
return new AsyncUnaryCall<TResponse>(
ResponseAsync(),
ResponseHeadersAsync(),
GetStatus,
GetTrailers,
Dispose);

AsyncUnaryCall creates startCallTask with Task.Run at lines 39-46.

The normal under-threshold fast path is here:

/// <summary>
/// Externalizes a payload if it exceeds the threshold.
/// </summary>
/// <param name="value">The value to potentially externalize.</param>
/// <param name="cancellation">Cancellation token.</param>
/// <returns>A task that returns the externalized token or the original value.</returns>
protected async Task<string?> MaybeExternalizeAsync(string? value, CancellationToken cancellation)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
int size = Encoding.UTF8.GetByteCount(value);
if (size < this.options.ThresholdBytes)
{
return value;
}
// Enforce a hard cap to prevent unbounded payload sizes
if (size > this.options.MaxPayloadBytes)
{
throw new PayloadStorageException(
$"Payload size {size / 1024} KB exceeds the configured maximum of {this.options.MaxPayloadBytes / 1024} KB. " +
"Reduce the payload size or increase the max payload size limit.");
}
return await this.payloadStore.UploadAsync(value!, cancellation);
}

5. Guidance for how to fix

Replace Task.Run with a local async starter method invoked directly. It should await request externalization and invoke the gRPC continuation only after externalization succeeds, retaining the existing exception, cancellation, response-header, status, trailer, and disposal behavior. Add interceptor tests for the no-externalization path, upload failure, cancellation, and disposal before startup completes.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions