Skip to content

Commit c9d2671

Browse files
authored
Complete health checks watch service on server shutting down (#2582)
1 parent b7af033 commit c9d2671

File tree

6 files changed

+359
-6
lines changed

6 files changed

+359
-6
lines changed

src/Grpc.AspNetCore.HealthChecks/GrpcHealthChecksOptions.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#endregion
1818

1919
using Microsoft.Extensions.Diagnostics.HealthChecks;
20+
using Microsoft.Extensions.Hosting;
2021

2122
namespace Grpc.AspNetCore.HealthChecks;
2223

@@ -39,4 +40,21 @@ public sealed class GrpcHealthChecksOptions
3940
/// published by <see cref="IHealthCheckPublisher"/> are returned.
4041
/// </remarks>
4142
public bool UseHealthChecksCache { get; set; }
43+
44+
/// <summary>
45+
/// Gets or sets a value indicating whether to suppress completing <c>Watch</c> health check calls when the application begins shutting down.
46+
/// The default value is <c>false</c>.
47+
/// </summary>
48+
/// <remarks>
49+
/// <para>
50+
/// When <c>false</c>, health checks <c>Watch</c> calls are completed with a status of NotServing when the server application begins shutting down.
51+
/// Shutdown is indicated by the <see cref="IHostApplicationLifetime.ApplicationStopping"/> token being raised and causes <c>Watch</c> to complete.
52+
/// When <c>true</c>, health checks <c>Watch</c> calls are left running. Running calls will be eventually be forcefully aborted when the server finishes shutting down.
53+
/// </para>
54+
/// <para>
55+
/// Completing the <c>Watch</c> call allows the server to gracefully exit. If <c>Watch</c> calls aren't shutdown then the server runs until
56+
/// <see cref="HostOptions.ShutdownTimeout"/> is exceeded and the server forcefully aborts remaining active requests.
57+
/// </para>
58+
/// </remarks>
59+
public bool SuppressCompletionOnShutdown { get; set; }
4260
}

src/Grpc.AspNetCore.HealthChecks/GrpcHealthChecksPublisher.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
#endregion
1818

19-
using System.Linq;
2019
using Grpc.Health.V1;
2120
using Grpc.HealthCheck;
2221
using Microsoft.Extensions.Diagnostics.HealthChecks;

src/Grpc.AspNetCore.HealthChecks/Internal/HealthServiceIntegration.cs

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
using Grpc.HealthCheck;
2323
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
2424
using Microsoft.Extensions.Diagnostics.HealthChecks;
25+
using Microsoft.Extensions.Hosting;
2526
using Microsoft.Extensions.Options;
2627

2728
namespace Grpc.AspNetCore.HealthChecks.Internal;
@@ -32,17 +33,20 @@ internal sealed class HealthServiceIntegration : Grpc.Health.V1.Health.HealthBas
3233
private readonly GrpcHealthChecksOptions _grpcHealthCheckOptions;
3334
private readonly HealthServiceImpl _healthServiceImpl;
3435
private readonly HealthCheckService _healthCheckService;
36+
private readonly IHostApplicationLifetime _applicationLifetime;
3537

3638
public HealthServiceIntegration(
3739
HealthServiceImpl healthServiceImpl,
3840
IOptions<HealthCheckOptions> healthCheckOptions,
3941
IOptions<GrpcHealthChecksOptions> grpcHealthCheckOptions,
40-
HealthCheckService healthCheckService)
42+
HealthCheckService healthCheckService,
43+
IHostApplicationLifetime applicationLifetime)
4144
{
4245
_healthCheckOptions = healthCheckOptions.Value;
4346
_grpcHealthCheckOptions = grpcHealthCheckOptions.Value;
4447
_healthServiceImpl = healthServiceImpl;
4548
_healthCheckService = healthCheckService;
49+
_applicationLifetime = applicationLifetime;
4650
}
4751

4852
public override Task<HealthCheckResponse> Check(HealthCheckRequest request, ServerCallContext context)
@@ -57,15 +61,84 @@ public override Task<HealthCheckResponse> Check(HealthCheckRequest request, Serv
5761
}
5862
}
5963

60-
public override Task Watch(HealthCheckRequest request, IServerStreamWriter<HealthCheckResponse> responseStream, ServerCallContext context)
64+
public override async Task Watch(HealthCheckRequest request, IServerStreamWriter<HealthCheckResponse> responseStream, ServerCallContext context)
6165
{
66+
ServerCallContext resolvedContext;
67+
IServerStreamWriter<HealthCheckResponse> resolvedResponseStream;
68+
69+
if (!_grpcHealthCheckOptions.SuppressCompletionOnShutdown)
70+
{
71+
// Create a linked token source to cancel the request if the application is stopping.
72+
// This is required because the server won't shut down gracefully if the request is still open.
73+
// The context needs to be wrapped because HealthServiceImpl is in an assembly that can't reference IHostApplicationLifetime.
74+
var cts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken, _applicationLifetime.ApplicationStopping);
75+
resolvedContext = new WrappedServerCallContext(context, cts);
76+
}
77+
else
78+
{
79+
resolvedContext = context;
80+
}
81+
6282
if (!_grpcHealthCheckOptions.UseHealthChecksCache)
6383
{
6484
// Stream writer replaces first health checks results from the cache with newly calculated health check results.
65-
responseStream = new WatchServerStreamWriter(this, request, responseStream, context.CancellationToken);
85+
resolvedResponseStream = new WatchServerStreamWriter(this, request, responseStream, context.CancellationToken);
86+
}
87+
else
88+
{
89+
resolvedResponseStream = responseStream;
90+
}
91+
92+
await _healthServiceImpl.Watch(request, resolvedResponseStream, resolvedContext);
93+
94+
// If the request is not canceled and the application is stopping then return NotServing before finishing.
95+
if (!context.CancellationToken.IsCancellationRequested && _applicationLifetime.ApplicationStopping.IsCancellationRequested)
96+
{
97+
await responseStream.WriteAsync(new HealthCheckResponse { Status = HealthCheckResponse.Types.ServingStatus.NotServing });
6698
}
99+
}
67100

68-
return _healthServiceImpl.Watch(request, responseStream, context);
101+
private sealed class WrappedServerCallContext : ServerCallContext
102+
{
103+
private readonly ServerCallContext _serverCallContext;
104+
private readonly CancellationTokenSource _cancellationTokenSource;
105+
106+
public WrappedServerCallContext(ServerCallContext serverCallContext, CancellationTokenSource cancellationTokenSource)
107+
{
108+
_serverCallContext = serverCallContext;
109+
_cancellationTokenSource = cancellationTokenSource;
110+
}
111+
112+
protected override string MethodCore => _serverCallContext.Method;
113+
protected override string HostCore => _serverCallContext.Host;
114+
protected override string PeerCore => _serverCallContext.Peer;
115+
protected override DateTime DeadlineCore => _serverCallContext.Deadline;
116+
protected override Metadata RequestHeadersCore => _serverCallContext.RequestHeaders;
117+
protected override CancellationToken CancellationTokenCore => _cancellationTokenSource.Token;
118+
protected override Metadata ResponseTrailersCore => _serverCallContext.ResponseTrailers;
119+
protected override Status StatusCore
120+
{
121+
get => _serverCallContext.Status;
122+
set => _serverCallContext.Status = value;
123+
}
124+
protected override WriteOptions? WriteOptionsCore
125+
{
126+
get => _serverCallContext.WriteOptions;
127+
set => _serverCallContext.WriteOptions = value;
128+
}
129+
protected override AuthContext AuthContextCore => _serverCallContext.AuthContext;
130+
131+
protected override IDictionary<object, object> UserStateCore => _serverCallContext.UserState;
132+
133+
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options)
134+
{
135+
return _serverCallContext.CreatePropagationToken(options);
136+
}
137+
138+
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
139+
{
140+
return _serverCallContext.WriteResponseHeadersAsync(responseHeaders);
141+
}
69142
}
70143

71144
private async Task<HealthCheckResponse> GetHealthCheckResponseAsync(string service, bool throwOnNotFound, CancellationToken cancellationToken)

src/Grpc.HealthCheck/HealthServiceImpl.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,13 @@ public override Task<HealthCheckResponse> Check(HealthCheckRequest request, Serv
141141
/// <returns>A task indicating completion of the handler.</returns>
142142
public override async Task Watch(HealthCheckRequest request, IServerStreamWriter<HealthCheckResponse> responseStream, ServerCallContext context)
143143
{
144+
// The call has already been canceled. Writing to the response will fail so immediately exit.
145+
// In the real world this situation is unlikely to happen as the server would have prevented a canceled call from making it this far.
146+
if (context.CancellationToken.IsCancellationRequested)
147+
{
148+
return;
149+
}
150+
144151
string service = request.Service;
145152

146153
// Channel is used to to marshall multiple callers updating status into a single queue.

0 commit comments

Comments
 (0)