Skip to content

Commit 276acd7

Browse files
committed
šŸ› Give the remotes health probe its own budget and make its timeout count
The probe used the remote's HttpClient, whose timeout is now the query time limit, so a short limit failed the health check of a remote that was fine. The probe's own 10 second budget never applied: its cancellation was caught by a no-op branch, so a hanging remote passed the check and was never disabled, and a shutdown waited out the full budget. The probe now runs on its own budget regardless of the query time limit; a remote that does not answer in time is disabled and fails the check, and a shutdown aborts the probe without a verdict on the remote.
1 parent 59c66a5 commit 276acd7

4 files changed

Lines changed: 167 additions & 34 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
namespace ServiceControl.UnitTests.ScatterGather;
2+
3+
using System;
4+
using System.Diagnostics;
5+
using System.Net;
6+
using System.Net.Http;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using NUnit.Framework;
10+
using ServiceBus.Management.Infrastructure.Settings;
11+
using ServiceControl.Operations;
12+
13+
/// <summary>
14+
/// The remotes health check probes each remote on its own budget: a remote that does not answer the probe is
15+
/// disabled until it answers again, and neither the query time limit nor a shutdown decides that.
16+
/// </summary>
17+
[TestFixture]
18+
class CheckRemotesTests
19+
{
20+
const string RemoteAddress = "http://audit-1/api";
21+
22+
[Test]
23+
public async Task A_remote_that_does_not_answer_the_probe_in_time_is_disabled_and_fails_the_check()
24+
{
25+
var settings = Settings();
26+
var factory = new FakeHttpClientFactory();
27+
factory.Register(settings.RemoteInstances[0], Hanging);
28+
29+
var check = new CheckRemotes(settings, factory, probeTimeout: TimeSpan.FromMilliseconds(200));
30+
31+
var result = await check.PerformCheck();
32+
33+
Assert.That(result.HasFailed, Is.True, "a hanging remote must fail the check");
34+
Assert.That(result.FailureReason, Does.Contain("did not respond"));
35+
Assert.That(settings.RemoteInstances[0].TemporarilyUnavailable, Is.True, "a hanging remote must be skipped by the queries until it answers again");
36+
}
37+
38+
[Test]
39+
public async Task The_probe_has_its_own_budget_and_is_not_bound_by_the_query_time_limit()
40+
{
41+
var settings = Settings();
42+
var factory = new FakeHttpClientFactory();
43+
factory.Register(settings.RemoteInstances[0], AnsweringAfter(TimeSpan.FromMilliseconds(300)), queryTimeLimit: TimeSpan.FromMilliseconds(50));
44+
45+
var check = new CheckRemotes(settings, factory, probeTimeout: TimeSpan.FromSeconds(5));
46+
47+
var result = await check.PerformCheck();
48+
49+
Assert.That(result.HasFailed, Is.False, "a short query time limit is not a verdict on the remote's health");
50+
Assert.That(settings.RemoteInstances[0].TemporarilyUnavailable, Is.False);
51+
}
52+
53+
[Test]
54+
public async Task A_probe_aborted_by_shutdown_does_not_disable_the_remote()
55+
{
56+
var settings = Settings();
57+
var factory = new FakeHttpClientFactory();
58+
factory.Register(settings.RemoteInstances[0], Hanging);
59+
60+
var check = new CheckRemotes(settings, factory, probeTimeout: TimeSpan.FromSeconds(10));
61+
using var shutdown = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
62+
var stopwatch = Stopwatch.StartNew();
63+
64+
await check.PerformCheck(shutdown.Token);
65+
66+
Assert.That(stopwatch.Elapsed, Is.LessThan(TimeSpan.FromSeconds(5)), "shutdown must abort the probe instead of waiting out its budget");
67+
Assert.That(settings.RemoteInstances[0].TemporarilyUnavailable, Is.False, "an aborted probe says nothing about the remote's health");
68+
}
69+
70+
[Test]
71+
public async Task A_remote_that_refuses_the_connection_is_disabled_and_fails_the_check()
72+
{
73+
var settings = Settings();
74+
var factory = new FakeHttpClientFactory();
75+
factory.Register(settings.RemoteInstances[0], (_, _) => throw new HttpRequestException("Connection refused"));
76+
77+
var result = await new CheckRemotes(settings, factory, probeTimeout: TimeSpan.FromSeconds(5)).PerformCheck();
78+
79+
Assert.That(result.HasFailed, Is.True);
80+
Assert.That(settings.RemoteInstances[0].TemporarilyUnavailable, Is.True);
81+
}
82+
83+
[Test]
84+
public async Task A_remote_that_answers_the_probe_is_enabled_again()
85+
{
86+
var settings = Settings();
87+
settings.RemoteInstances[0].TemporarilyUnavailable = true;
88+
var factory = new FakeHttpClientFactory();
89+
factory.Register(settings.RemoteInstances[0], AnsweringAfter(TimeSpan.Zero));
90+
91+
var result = await new CheckRemotes(settings, factory, probeTimeout: TimeSpan.FromSeconds(5)).PerformCheck();
92+
93+
Assert.That(result.HasFailed, Is.False);
94+
Assert.That(settings.RemoteInstances[0].TemporarilyUnavailable, Is.False);
95+
}
96+
97+
static Settings Settings() => new() { RemoteInstances = [new RemoteInstanceSetting(RemoteAddress)] };
98+
99+
static readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> Hanging = async (_, token) =>
100+
{
101+
await Task.Delay(Timeout.Infinite, token);
102+
return null;
103+
};
104+
105+
static Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> AnsweringAfter(TimeSpan delay) => async (_, token) =>
106+
{
107+
await Task.Delay(delay, token);
108+
return new HttpResponseMessage(HttpStatusCode.OK);
109+
};
110+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#pragma warning disable PS0003 // Make the CancellationToken parameter optional — HttpMessageHandler.SendAsync override signature is fixed
2+
3+
namespace ServiceControl.UnitTests.ScatterGather;
4+
5+
using System;
6+
using System.Collections.Concurrent;
7+
using System.Net.Http;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using ServiceBus.Management.Infrastructure.Settings;
11+
12+
/// <summary>
13+
/// Hands out a client per remote instance whose answers are scripted, with the timeout the real
14+
/// remote clients get from <see cref="ServiceControl.Infrastructure.WebApi.RemoteInstanceServiceCollectionExtensions"/>: the query time limit.
15+
/// </summary>
16+
class FakeHttpClientFactory : IHttpClientFactory
17+
{
18+
readonly ConcurrentDictionary<string, (HttpMessageHandler Handler, string BaseAddress, TimeSpan Timeout)> handlers = new();
19+
20+
public void Register(RemoteInstanceSetting remote, Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder, TimeSpan? queryTimeLimit = null) =>
21+
handlers[remote.InstanceId] = (new StubHandler(responder), remote.BaseAddress, queryTimeLimit ?? TimeSpan.FromSeconds(100));
22+
23+
public HttpClient CreateClient(string name)
24+
{
25+
var (handler, baseAddress, timeout) = handlers[name];
26+
return new HttpClient(handler, disposeHandler: false) { BaseAddress = new Uri(baseAddress), Timeout = timeout };
27+
}
28+
29+
class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder) : HttpMessageHandler
30+
{
31+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => responder(request, cancellationToken);
32+
}
33+
}

ā€Žsrc/ServiceControl.UnitTests/ScatterGather/IncompleteResultsTests.csā€Ž

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
#pragma warning disable PS0003 // Make the CancellationToken parameter optional — HttpMessageHandler.SendAsync override signature is fixed
2-
31
namespace ServiceControl.UnitTests.ScatterGather;
42

53
using System;
6-
using System.Collections.Concurrent;
74
using System.Collections.Generic;
85
using System.Linq;
96
using System.Net;
@@ -239,23 +236,4 @@ class TestApi(Settings settings, IHttpClientFactory factory, Func<CancellationTo
239236
{
240237
protected override Task<QueryResult<IList<MessagesView>>> LocalQuery(ScatterGatherApiMessageViewContext input, CancellationToken cancellationToken = default) => local(cancellationToken);
241238
}
242-
243-
class FakeHttpClientFactory : IHttpClientFactory
244-
{
245-
readonly ConcurrentDictionary<string, (HttpMessageHandler Handler, string BaseAddress)> handlers = new();
246-
247-
public void Register(RemoteInstanceSetting remote, Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder) =>
248-
handlers[remote.InstanceId] = (new StubHandler(responder), remote.BaseAddress);
249-
250-
public HttpClient CreateClient(string name)
251-
{
252-
var (handler, baseAddress) = handlers[name];
253-
return new HttpClient(handler, disposeHandler: false) { BaseAddress = new Uri(baseAddress) };
254-
}
255-
}
256-
257-
class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder) : HttpMessageHandler
258-
{
259-
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => responder(request, cancellationToken);
260-
}
261239
}

ā€Žsrc/ServiceControl/Operations/CheckRemotes.csā€Ž

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,14 @@
1111

1212
class CheckRemotes : CustomCheck
1313
{
14-
public CheckRemotes(Settings settings, IHttpClientFactory httpClientFactory) : base("ServiceControl Remotes", "Health", TimeSpan.FromSeconds(30))
14+
public CheckRemotes(Settings settings, IHttpClientFactory httpClientFactory) : this(settings, httpClientFactory, TimeSpan.FromSeconds(10))
15+
{
16+
}
17+
18+
internal CheckRemotes(Settings settings, IHttpClientFactory httpClientFactory, TimeSpan probeTimeout) : base("ServiceControl Remotes", "Health", TimeSpan.FromSeconds(30))
1519
{
1620
this.httpClientFactory = httpClientFactory;
21+
this.probeTimeout = probeTimeout;
1722
remoteInstanceSetting = settings.RemoteInstances;
1823
remoteQueryTasks = new List<Task>(remoteInstanceSetting.Length);
1924
}
@@ -22,11 +27,12 @@ public override async Task<CheckResult> PerformCheck(CancellationToken cancellat
2227
{
2328
try
2429
{
25-
var queryTimeout = TimeSpan.FromSeconds(10);
26-
using var cancellationTokenSource = new CancellationTokenSource(queryTimeout);
30+
using var probe = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
31+
probe.CancelAfter(probeTimeout);
32+
2733
foreach (var remote in remoteInstanceSetting)
2834
{
29-
remoteQueryTasks.Add(CheckSuccessStatusCode(remote, queryTimeout, cancellationTokenSource.Token));
35+
remoteQueryTasks.Add(CheckSuccessStatusCode(remote, probe, cancellationToken));
3036
}
3137

3238
try
@@ -59,12 +65,15 @@ public override async Task<CheckResult> PerformCheck(CancellationToken cancellat
5965
}
6066
}
6167

62-
async Task CheckSuccessStatusCode(RemoteInstanceSetting remoteSettings, TimeSpan queryTimeout, CancellationToken cancellationToken)
68+
async Task CheckSuccessStatusCode(RemoteInstanceSetting remoteSettings, CancellationTokenSource probe, CancellationToken cancellationToken)
6369
{
6470
try
6571
{
6672
var client = httpClientFactory.CreateClient(remoteSettings.InstanceId);
67-
var response = await client.GetAsync("/api", cancellationToken);
73+
// The remote's client is configured with the query time limit; the probe runs on its own budget.
74+
client.Timeout = Timeout.InfiniteTimeSpan;
75+
76+
var response = await client.GetAsync("/api", probe.Token);
6877
response.EnsureSuccessStatusCode();
6978
remoteSettings.TemporarilyUnavailable = false;
7079
}
@@ -73,18 +82,21 @@ async Task CheckSuccessStatusCode(RemoteInstanceSetting remoteSettings, TimeSpan
7382
remoteSettings.TemporarilyUnavailable = true;
7483
throw new TimeoutException($"The remote instance at '{remoteSettings.BaseAddress}' doesn't seem to be available. It will be temporarily disabled. Reason: {e.Message}", e);
7584
}
76-
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
77-
{
78-
// Cancelled, noop
79-
}
80-
catch (OperationCanceledException e) // Intentional, OCE gracefully handled by other catch
85+
catch (OperationCanceledException e) when (probe.Token.IsCancellationRequested)
8186
{
87+
if (cancellationToken.IsCancellationRequested)
88+
{
89+
// Shutting down; an aborted probe says nothing about the remote's health
90+
return;
91+
}
92+
8293
remoteSettings.TemporarilyUnavailable = true;
83-
throw new TimeoutException($"The remote at '{remoteSettings.BaseAddress}' did not respond within the allotted time of '{queryTimeout}'. It will be temporarily disabled.", e);
94+
throw new TimeoutException($"The remote at '{remoteSettings.BaseAddress}' did not respond within the allotted time of '{probeTimeout}'. It will be temporarily disabled.", e);
8495
}
8596
}
8697

8798
readonly IHttpClientFactory httpClientFactory;
99+
readonly TimeSpan probeTimeout;
88100
RemoteInstanceSetting[] remoteInstanceSetting;
89101
List<Task> remoteQueryTasks;
90102
}

0 commit comments

Comments
Ā (0)