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.
1. What is the issue?
The Azure Blob payload interceptor wraps request externalization and continuation startup in
Task.Runfor 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:
durabletask-dotnet/src/Extensions/AzureBlobPayloads/Interceptors/PayloadInterceptor.cs
Lines 30 to 108 in 883211a
AsyncUnaryCallcreatesstartCallTaskwithTask.Runat lines 39-46.The normal under-threshold fast path is here:
durabletask-dotnet/src/Extensions/AzureBlobPayloads/Interceptors/PayloadInterceptor.cs
Lines 163 to 191 in 883211a
5. Guidance for how to fix
Replace
Task.Runwith 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.