Skip to content

Reduce per-call allocations in ChannelRecreatingCallInvoker outcome tracking#782

Open
berndverst wants to merge 2 commits into
mainfrom
berndverst-improved-bassoon
Open

Reduce per-call allocations in ChannelRecreatingCallInvoker outcome tracking#782
berndverst wants to merge 2 commits into
mainfrom
berndverst-improved-bassoon

Conversation

@berndverst

Copy link
Copy Markdown
Member

Fixes #777

Problem

ChannelRecreatingCallInvoker.ObserveOutcome<TResponse> passed a (this, methodFullName) ValueTuple<ChannelRecreatingCallInvoker, string> as the object? state argument to Task.ContinueWith. Since state is typed object?, this boxes the tuple struct on every unary RPC call.

Fix

  • Added a readonly Action<Task, object?> onUnaryCallCompleted field, initialized once in the constructor to this.OnUnaryCallCompleted (an instance-method group), instead of allocating a lambda + tuple per call.
  • Changed ObserveOutcome to take a non-generic Task parameter (leveraging the fact that Task<TResponse> implicitly converts to Task) so the call resolves to Task's inherited non-generic ContinueWith(Action<Task, object?>, object?, CancellationToken, TaskContinuationOptions, TaskScheduler) overload.
  • The method-name diagnostic is now passed directly as the state argument as a string — already a reference type, so no boxing occurs converting it to/from object?.
  • Extracted the original success/failure classification logic verbatim into a new OnUnaryCallCompleted(Task, object?) instance method.

What's preserved (unchanged)

  • Success/failure classification logic (RanToCompletionRecordSuccess(); RpcException inner exception → RecordFailure(statusCode, methodFullName)).
  • Method-name diagnostics passed to RecordFailure.
  • TaskContinuationOptions.ExecuteSynchronously + TaskScheduler.Default scheduling (no sync-context capture).
  • Thread safety (RecordSuccess/RecordFailure internals untouched).
  • Channel lifecycle/disposal/recreate-cooldown/single-flight behavior (untouched).
  • Public API — ObserveOutcome/OnUnaryCallCompleted are private implementation details; no public surface changed.

Allocation impact

A standalone scratch benchmark (not committed; 200,000 iterations, Release build, JIT warm-up, GC.GetAllocatedBytesForCurrentThread) comparing the old boxed-tuple ContinueWith call against the new cached-delegate call showed:

Approach Bytes/call
Old (boxed tuple) 216.00
New (cached delegate) 184.00
Reduction ~14.8% (32 bytes/call)

The remaining ~184 bytes/call is inherent to Task.ContinueWith's internal continuation/Task bookkeeping and is out of scope for this change (the issue explicitly calls for removing the boxed tuple/continuation overhead without a broader rewrite of the completion mechanism).

Tests

Added four focused regression tests to GrpcDurableTaskClientChannelRecreationTests.cs:

  • AsyncUnaryCall_SuccessAfterFailure_ResetsConsecutiveFailureCountAndSuppressesRecreate — verifies an interleaved success resets the consecutive-failure streak and the recreator is not invoked.
  • AsyncUnaryCall_DeadlineExceededOnAllowedLongPollMethod_DoesNotCountTowardThreshold — verifies DeadlineExceeded on the long-poll allow-listed method never counts toward the recreate threshold.
  • AsyncUnaryCall_DeadlineExceededOnRegularMethod_CountsTowardThresholdAndTriggersRecreate — verifies DeadlineExceeded on a regular method does count and triggers a channel recreate.
  • AsyncUnaryCall_MultipleCalls_ReuseSameCachedOutcomeDelegateInstance — verifies the same onUnaryCallCompleted delegate instance is reused across multiple RPCs (proving no per-call delegate allocation).

All 51 tests in Client.Grpc.Tests pass (dotnet test test\Client\Grpc.Tests\Client.Grpc.Tests.csproj).

Scope

This PR touches only src/Client/Grpc/ChannelRecreatingCallInvoker.cs and its dedicated test file, per the issue's scope. No other issues/changes are stacked on this branch.

…racking

ObserveOutcome previously passed a (this, methodFullName) ValueTuple as the
object? state argument to Task.ContinueWith, which boxes the tuple on every
unary RPC. It also relied on a non-capturing lambda for the continuation.

Cache a single per-instance Action<Task, object?> delegate
(onUnaryCallCompleted) created once in the constructor, and pass the
method-name string directly as ContinueWith state (already a reference type,
so no boxing). ObserveOutcome now takes a non-generic Task parameter so the
call resolves to Task's inherited non-generic ContinueWith(Action<Task,
object?>, object?, ...) overload for both generic and non-generic
Task-returning calls.

Success/failure classification, method-name diagnostics,
TaskContinuationOptions.ExecuteSynchronously + TaskScheduler.Default
scheduling, thread safety, channel lifecycle/disposal behavior, and the
public API are all unchanged -- ObserveOutcome/OnUnaryCallCompleted are
private implementation details.

A standalone allocation comparison (200,000 iterations, Release, warmed up)
showed a ~14.8% reduction in per-call allocation (216 -> 184 bytes/call) from
removing the boxed tuple; the remainder is inherent to Task.ContinueWith's
internal continuation/Task bookkeeping and out of scope per the issue.

Added focused regression tests to
GrpcDurableTaskClientChannelRecreationTests.cs covering: failure-count reset
after an interleaved success, DeadlineExceeded suppression on long-poll
methods, DeadlineExceeded counting/triggering recreate on regular methods,
and cached-delegate identity across multiple calls.

Fixes #777

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3c971e29-a4bb-494b-b272-dce0c7aede23
Copilot AI review requested due to automatic review settings July 24, 2026 22:05
TaskScheduler.Default);
}

void OnUnaryCallCompleted(Task task, object? state)

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

Reduces allocation overhead in the gRPC client’s channel-recreation outcome tracking by removing per-unary-call boxing/closure creation in ChannelRecreatingCallInvoker, and adds targeted regression tests to validate failure-counting behavior and delegate reuse.

Changes:

  • Cache the unary-call completion continuation delegate once per ChannelRecreatingCallInvoker instance and pass the method name as a string continuation state to avoid boxed tuple allocations.
  • Refactor unary outcome observation into a dedicated OnUnaryCallCompleted(Task, object?) method.
  • Add regression tests covering success/failure streak behavior, DeadlineExceeded allow-list behavior, and cached-delegate reuse.

Reviewed changes

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

File Description
src/Client/Grpc/ChannelRecreatingCallInvoker.cs Avoids per-unary-call tuple boxing and per-call delegate allocation by reusing a cached continuation delegate and passing method name as string state.
test/Client/Grpc.Tests/GrpcDurableTaskClientChannelRecreationTests.cs Adds regression tests for consecutive-failure tracking, allow-listed timeouts, recreate triggering, and delegate instance reuse.

Comment on lines +41 to +45
// Cached once per invoker instance (instead of once per RPC) so ObserveOutcome's ContinueWith call
// does not allocate a new delegate for every unary call. The method-name diagnostic is threaded
// through as the ContinueWith `state` argument (a string, already a reference type) instead of a
// boxed (self, methodName) tuple, eliminating the per-call heap allocation entirely.
readonly Action<Task, object?> onUnaryCallCompleted;
Comment on lines +249 to +253
// Arrange/Act: the outcome-observation delegate must be created exactly once per invoker
// instance (in the constructor) and reused for every call, rather than allocated per RPC.
GrpcChannel channel = GrpcChannel.ForAddress("http://cached-delegate.client.test");
GrpcDurableTaskClientOptions options = new() { Channel = channel };
options.SetChannelRecreator((existingChannel, ct) => Task.FromResult(existingChannel));
AsyncUnaryCall_MultipleCalls_ReuseSameCachedOutcomeDelegateInstance used
GrpcChannel.ForAddress against a real (unresolvable) hostname with no
deadline and a broad catch, which could hang or behave unpredictably in CI
depending on DNS/proxy/network availability.

Switch to the same deterministic CallbackHttpMessageHandler pattern used by
the other tests in this file (always returns Unavailable), add an explicit
short deadline, and narrow the catch to the expected RpcException. The
delegate-identity assertion under test is unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3c971e29-a4bb-494b-b272-dce0c7aede23
Copilot AI review requested due to automatic review settings July 24, 2026 23:13

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 no new comments.

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: reduce per-call allocations in channel recreation outcome tracking

2 participants