@@ -22,27 +22,14 @@ namespace Microsoft.DurableTask.Client.Grpc;
2222/// </remarks>
2323sealed class ChannelRecreatingCallInvoker : CallInvoker , IAsyncDisposable
2424{
25- /// <summary>
26- /// Methods on which a <see cref="StatusCode.DeadlineExceeded"/> response is expected behavior
27- /// (long-poll-style waits) and must NOT be counted toward the recreate threshold.
28- /// </summary>
29- static readonly HashSet < string > DeadlineExceededAllowedMethods = new ( StringComparer . Ordinal )
30- {
31- "/TaskHubSidecarService/WaitForInstanceCompletion" ,
32- "/TaskHubSidecarService/WaitForInstanceStart" ,
33- } ;
34-
3525 readonly Func < GrpcChannel , CancellationToken , Task < GrpcChannel > > recreator ;
3626 readonly int failureThreshold ;
3727 readonly TimeSpan minRecreateInterval ;
3828 readonly bool ownsChannel ;
3929 readonly ILogger logger ;
4030
41- // Cached once per invoker instance (instead of once per RPC) so ObserveOutcome's ContinueWith call
42- // does not allocate a new delegate for every unary call. The method-name diagnostic is threaded
43- // through as the ContinueWith `state` argument (a string, already a reference type) instead of a
44- // boxed (self, methodName) tuple, eliminating that per-call delegate and boxed-tuple allocation
45- // (Task.ContinueWith's own internal continuation/Task bookkeeping is unaffected).
31+ // Reuse the continuation delegate. Calls without a caller token also reuse the method-name string
32+ // as continuation state; cancellable calls carry the token so local cancellation stays neutral.
4633 readonly Action < Task , object ? > onUnaryCallCompleted ;
4734
4835 // Cancelled in DisposeAsync so an in-flight RecreateAsync stops promptly and does not leak the
@@ -96,7 +83,7 @@ public override TResponse BlockingUnaryCall<TRequest, TResponse>(
9683 }
9784 catch ( RpcException ex )
9885 {
99- this . RecordFailure ( ex . StatusCode , method . FullName ) ;
86+ this . RecordFailure ( ex . StatusCode , method . FullName , options . CancellationToken ) ;
10087 throw ;
10188 }
10289 }
@@ -106,7 +93,7 @@ public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
10693 {
10794 TransportState current = Volatile . Read ( ref this . state ) ;
10895 AsyncUnaryCall < TResponse > call = current . Invoker . AsyncUnaryCall ( method , host , options , request ) ;
109- this . ObserveOutcome ( call . ResponseAsync , method . FullName ) ;
96+ this . ObserveOutcome ( call . ResponseAsync , method . FullName , options . CancellationToken ) ;
11097 return call ;
11198 }
11299
@@ -203,30 +190,29 @@ static TimeSpan ElapsedSince(long previousTimestamp, long nowTimestamp)
203190 return TimeSpan . FromSeconds ( ( double ) elapsedTicks / Stopwatch . Frequency ) ;
204191 }
205192
206- void ObserveOutcome ( Task responseAsync , string methodFullName )
193+ void ObserveOutcome ( Task responseAsync , string methodFullName , CancellationToken cancellation )
207194 {
208- // Use ContinueWith with TaskScheduler.Default so we don't capture sync context. Both the
209- // continuation delegate (this.onUnaryCallCompleted, cached once per invoker instance) and the
210- // continuation state (methodFullName, already a reference-typed string) are allocation-free
211- // per call: no boxed tuple and no per-call delegate closure.
195+ object continuationState = cancellation . CanBeCanceled ? ( methodFullName , cancellation ) : methodFullName ;
212196 responseAsync . ContinueWith (
213197 this . onUnaryCallCompleted ,
214- methodFullName ,
198+ continuationState ,
215199 CancellationToken . None ,
216200 TaskContinuationOptions . ExecuteSynchronously ,
217201 TaskScheduler . Default ) ;
218202 }
219203
220204 void OnUnaryCallCompleted ( Task task , object ? continuationState )
221205 {
222- string methodFullName = ( string ) continuationState ! ;
223206 if ( task . Status == TaskStatus . RanToCompletion )
224207 {
225208 this . RecordSuccess ( ) ;
226209 }
227210 else if ( task . Exception ? . InnerException is RpcException rpcEx )
228211 {
229- this . RecordFailure ( rpcEx . StatusCode , methodFullName ) ;
212+ ( string methodFullName , CancellationToken cancellation ) = continuationState is string method
213+ ? ( method , default )
214+ : ( ( string , CancellationToken ) ) continuationState ! ;
215+ this . RecordFailure ( rpcEx . StatusCode , methodFullName , cancellation ) ;
230216 }
231217 }
232218
@@ -235,8 +221,14 @@ void RecordSuccess()
235221 Volatile . Write ( ref this . consecutiveFailures , 0 ) ;
236222 }
237223
238- void RecordFailure ( StatusCode status , string methodFullName )
224+ void RecordFailure ( StatusCode status , string methodFullName , CancellationToken cancellation )
239225 {
226+ if ( status == StatusCode . Cancelled && cancellation . IsCancellationRequested )
227+ {
228+ // gRPC does not identify cancellation's origin; an actually cancelled caller token is neutral.
229+ return ;
230+ }
231+
240232 // Only count statuses that indicate an actual transport problem, not application-level errors:
241233 // * Unavailable — half-open connection, peer reset, or dead routing target.
242234 // * DeadlineExceeded — the call exceeded the *client-supplied* deadline. This is a
@@ -249,7 +241,7 @@ void RecordFailure(StatusCode status, string methodFullName)
249241 bool counts = status switch
250242 {
251243 StatusCode . Unavailable => true ,
252- StatusCode . DeadlineExceeded => ! DeadlineExceededAllowedMethods . Contains ( methodFullName ) ,
244+ StatusCode . DeadlineExceeded => ! ProtoUtils . IsLongPollingMethod ( methodFullName ) ,
253245 _ => false ,
254246 } ;
255247
0 commit comments