Skip to content

Commit 26913b3

Browse files
ReubenBondCopilot
andauthored
feat(membership): detect local stalls during direct probes (#9979)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 5943103 commit 26913b3

7 files changed

Lines changed: 818 additions & 102 deletions

File tree

src/Orleans.Runtime/MembershipService/LocalSiloHealthEventHistory.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ private bool Overlaps(LocalSiloHealthEvent healthEvent, long startTimestamp, lon
242242
private static bool IsStateful(LocalSiloHealthCheckKind kind)
243243
=> kind is LocalSiloHealthCheckKind.MembershipStatus
244244
or LocalSiloHealthCheckKind.HealthCheckParticipant
245-
or LocalSiloHealthCheckKind.ThreadPoolQueueDelay
245+
or LocalSiloHealthCheckKind.ThreadPoolStall
246246
or LocalSiloHealthCheckKind.ProbeRequests
247247
or LocalSiloHealthCheckKind.ProbeResponses;
248248

src/Orleans.Runtime/MembershipService/LocalSiloHealthMonitor.cs

Lines changed: 61 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ internal enum LocalSiloHealthCheckKind
2727
MembershipStatus,
2828
SiloSuspected,
2929
HealthCheckParticipant,
30-
ThreadPoolQueueDelay,
30+
ThreadPoolStall,
3131
ProbeRequests,
3232
ProbeResponses,
3333
GarbageCollectionPause,
@@ -53,6 +53,24 @@ public ImmutableArray<string> Complaints
5353

5454
internal interface ILocalSiloHealthMonitor
5555
{
56+
/// <summary>
57+
/// Returns a timestamp from the stall detector's time source.
58+
/// </summary>
59+
/// <returns>A timestamp suitable for use with <see cref="GetStallDurationAsync"/>.</returns>
60+
long GetTimestamp();
61+
62+
/// <summary>
63+
/// Waits for the stall detector to sample past the end of the interval, then returns the detected stall duration.
64+
/// </summary>
65+
/// <param name="startTimestamp">The start of the interval.</param>
66+
/// <param name="endTimestamp">The end of the interval.</param>
67+
/// <param name="cancellationToken">A token which cancels the wait.</param>
68+
/// <returns>The detected stall duration.</returns>
69+
ValueTask<TimeSpan> GetStallDurationAsync(
70+
long startTimestamp,
71+
long endTimestamp,
72+
CancellationToken cancellationToken);
73+
5674
/// <summary>
5775
/// Returns the aggregate local health status over the provided interval.
5876
/// </summary>
@@ -101,15 +119,16 @@ void RecordHealthEvent(
101119
/// <item><description>Check that no other silo suspects this silo.</description></item>
102120
/// <item><description>Check for recently received successful ping responses (via <see cref="IProbeHealthMonitor"/>).</description></item>
103121
/// <item><description>Check for recently received ping requests (via <see cref="IProbeHealthMonitor"/>).</description></item>
104-
/// <item><description>Check that the .NET Thread Pool is able to process work items within one second.</description></item>
122+
/// <item><description>Check that the .NET Thread Pool executes periodic timer callbacks on schedule.</description></item>
105123
/// <item><description>Check that local async timers have been firing on-time (within 3 seconds of their due time).</description></item>
106124
/// </list>
107125
/// </remarks>
108126
internal partial class LocalSiloHealthMonitor :
109127
ILifecycleParticipant<ISiloLifecycle>,
110128
ILifecycleObserver,
111129
ILocalSiloHealthMonitor,
112-
ILocalSiloHealthEventRecorder
130+
ILocalSiloHealthEventRecorder,
131+
IDisposable
113132
{
114133
internal const int MaxScore = 8;
115134
private static readonly TimeSpan HistoryDuration = TimeSpan.FromMinutes(1);
@@ -122,7 +141,7 @@ internal partial class LocalSiloHealthMonitor :
122141
private readonly ILogger<LocalSiloHealthMonitor> _log;
123142
private readonly ClusterMembershipOptions _clusterMembershipOptions;
124143
private readonly IAsyncTimer _degradationCheckTimer;
125-
private readonly ThreadPoolMonitor _threadPoolMonitor;
144+
private readonly ThreadPoolStallDetector _stallDetector;
126145
private readonly TimeProvider _timeProvider;
127146
#if NET9_0_OR_GREATER
128147
private readonly Lock _historyLock = new();
@@ -166,12 +185,32 @@ public LocalSiloHealthMonitor(
166185
MinimumCheckPeriod,
167186
nameof(LocalSiloHealthMonitor),
168187
timeProvider);
169-
_threadPoolMonitor = new ThreadPoolMonitor(loggerFactory.CreateLogger<ThreadPoolMonitor>(), timeProvider);
188+
var stallRetentionPeriod = _clusterMembershipOptions.MaxProbeTimeout + ThreadPoolStallDetector.DetectionPeriod;
189+
if (stallRetentionPeriod < HistoryDuration)
190+
{
191+
stallRetentionPeriod = HistoryDuration;
192+
}
193+
194+
_stallDetector = new(
195+
loggerFactory.CreateLogger<ThreadPoolStallDetector>(),
196+
timeProvider,
197+
ThreadPoolStallDetector.DetectionPeriod,
198+
stallRetentionPeriod);
170199
}
171200

172201
/// <inheritdoc />
173202
public ImmutableArray<string> Complaints { get; private set; } = [];
174203

204+
/// <inheritdoc />
205+
public long GetTimestamp() => _timeProvider.GetTimestamp();
206+
207+
/// <inheritdoc />
208+
public ValueTask<TimeSpan> GetStallDurationAsync(
209+
long startTimestamp,
210+
long endTimestamp,
211+
CancellationToken cancellationToken)
212+
=> _stallDetector.GetStallDurationAsync(startTimestamp, endTimestamp, cancellationToken);
213+
175214
/// <inheritdoc />
176215
public LocalSiloHealthStatus GetLocalHealthStatus(
177216
long startTimestamp,
@@ -265,7 +304,7 @@ private LocalSiloHealthStatus EnsureHealthCheck(
265304
var events = new List<LocalSiloHealthEvent>(_healthCheckParticipants.Count + 1);
266305
var complaints = new List<string>();
267306
CheckLocalHealthCheckParticipants(now.UtcDateTime, timestamp, events, complaints);
268-
CheckThreadPoolQueueDelay(timestamp, events, complaints);
307+
CheckThreadPoolStalls(timestamp, events, complaints);
269308
AddEvents(timestamp, events);
270309

271310
var score = GetScore(events);
@@ -350,28 +389,30 @@ private void AddEvents(long timestamp, List<LocalSiloHealthEvent> events)
350389
}
351390
}
352391

353-
private void CheckThreadPoolQueueDelay(
392+
private void CheckThreadPoolStalls(
354393
long timestamp,
355394
List<LocalSiloHealthEvent> events,
356395
List<string> complaints)
357396
{
358-
var delay = _threadPoolMonitor.MeasureQueueDelay();
359-
var score = (int)delay.TotalSeconds;
397+
var stallDuration = _stallDetector.GetMaximumStallDuration(
398+
SubtractTimestamp(timestamp, MinimumCheckPeriod),
399+
timestamp);
400+
var score = (int)stallDuration.TotalSeconds;
360401
string? complaint = null;
361402
if (score >= 1)
362403
{
363-
complaint = $".NET Thread Pool is exhibiting delays of {delay.TotalSeconds}s. This can indicate .NET Thread Pool starvation, very long .NET GC pauses, or other runtime or machine pauses.";
404+
complaint = $".NET Thread Pool execution stalled for {stallDuration.TotalSeconds}s. This can indicate .NET Thread Pool starvation, very long .NET GC pauses, or other runtime or machine pauses.";
364405
complaints.Add(complaint);
365406
}
366407

367408
events.Add(new(
368409
timestamp,
369-
LocalSiloHealthCheckKind.ThreadPoolQueueDelay,
370-
GetCategory(LocalSiloHealthCheckKind.ThreadPoolQueueDelay),
410+
LocalSiloHealthCheckKind.ThreadPoolStall,
411+
GetCategory(LocalSiloHealthCheckKind.ThreadPoolStall),
371412
Source: null,
372413
score,
373414
complaint,
374-
delay,
415+
stallDuration,
375416
score >= 10 ? LogLevel.Error : LogLevel.Warning));
376417
}
377418

@@ -610,9 +651,9 @@ private void LogHealthDetails(ImmutableArray<LocalSiloHealthEvent> events)
610651
continue;
611652
}
612653

613-
if (healthEvent.Kind == LocalSiloHealthCheckKind.ThreadPoolQueueDelay)
654+
if (healthEvent.Kind == LocalSiloHealthCheckKind.ThreadPoolStall)
614655
{
615-
LogThreadPoolDelay(healthEvent.LogLevel, healthEvent.Duration?.TotalSeconds ?? 0);
656+
LogThreadPoolStall(healthEvent.LogLevel, healthEvent.Duration?.TotalSeconds ?? 0);
616657
}
617658
else
618659
{
@@ -644,79 +685,16 @@ public async Task OnStop(CancellationToken ct)
644685
}
645686
}
646687

647-
/// <summary>
648-
/// Measures queue delay on the .NET <see cref="ThreadPool"/>.
649-
/// </summary>
650-
private class ThreadPoolMonitor
688+
public void Dispose()
651689
{
652-
private static readonly WaitCallback Callback = state => ((ThreadPoolMonitor)state!).Execute();
653-
#if NET9_0_OR_GREATER
654-
private readonly Lock _lockObj = new();
655-
#else
656-
private readonly object _lockObj = new();
657-
#endif
658-
private readonly ILogger<ThreadPoolMonitor> _log;
659-
private readonly TimeProvider _timeProvider;
660-
private bool _scheduled;
661-
private TimeSpan _lastQueueDelay;
662-
private long _queueDelayTimestamp;
663-
664-
public ThreadPoolMonitor(ILogger<ThreadPoolMonitor> log, TimeProvider timeProvider)
665-
{
666-
_log = log;
667-
_timeProvider = timeProvider;
668-
}
669-
670-
public TimeSpan MeasureQueueDelay()
671-
{
672-
bool shouldSchedule;
673-
TimeSpan delay;
674-
lock (_lockObj)
675-
{
676-
var currentQueueDelay = _scheduled ? _timeProvider.GetElapsedTime(_queueDelayTimestamp) : TimeSpan.Zero;
677-
delay = currentQueueDelay > _lastQueueDelay ? currentQueueDelay : _lastQueueDelay;
678-
679-
if (!_scheduled)
680-
{
681-
_scheduled = true;
682-
shouldSchedule = true;
683-
_queueDelayTimestamp = _timeProvider.GetTimestamp();
684-
}
685-
else
686-
{
687-
shouldSchedule = false;
688-
}
689-
}
690-
691-
if (shouldSchedule)
692-
{
693-
_ = ThreadPool.UnsafeQueueUserWorkItem(Callback, this);
694-
}
695-
696-
return delay;
697-
}
698-
699-
private void Execute()
700-
{
701-
try
702-
{
703-
lock (_lockObj)
704-
{
705-
_scheduled = false;
706-
_lastQueueDelay = _timeProvider.GetElapsedTime(_queueDelayTimestamp);
707-
}
708-
}
709-
catch (Exception exception)
710-
{
711-
LocalSiloHealthMonitor.LogThreadPoolDelayMonitorError(_log, exception);
712-
}
713-
}
690+
_degradationCheckTimer.Dispose();
691+
_stallDetector.Dispose();
714692
}
715693

716694
[LoggerMessage(
717-
Message = ".NET Thread Pool is exhibiting delays of {ThreadPoolQueueDelaySeconds}s. This can indicate .NET Thread Pool starvation, very long .NET GC pauses, or other runtime or machine pauses."
695+
Message = ".NET Thread Pool execution stalled for {ThreadPoolStallSeconds}s. This can indicate .NET Thread Pool starvation, very long .NET GC pauses, or other runtime or machine pauses."
718696
)]
719-
private partial void LogThreadPoolDelay(LogLevel logLevel, double threadPoolQueueDelaySeconds);
697+
private partial void LogThreadPoolStall(LogLevel logLevel, double threadPoolStallSeconds);
720698

721699
[LoggerMessage(
722700
Message = "{Kind} health check for {Source} reported: {Complaint}"
@@ -763,12 +741,6 @@ private partial void LogRecordedHealthIssue(
763741
)]
764742
private partial void LogSelfMonitoringDegraded(int score, int maxScore, string complaints);
765743

766-
[LoggerMessage(
767-
Level = LogLevel.Error,
768-
Message = "Exception monitoring .NET thread pool delay"
769-
)]
770-
private static partial void LogThreadPoolDelayMonitorError(ILogger logger, Exception exception);
771-
772744
[LoggerMessage(
773745
Level = LogLevel.Error,
774746
Message = "Error while monitoring local silo health"

src/Orleans.Runtime/MembershipService/SiloHealthMonitor.cs

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,15 @@ private async Task Run()
207207
localDegradationScore = GetLocalDegradationScore(previousProbePeriod);
208208
var timeout = CalculateProbeTimeout(failureDetector, options, localDegradationScore, isDirectProbe, Debugger.IsAttached);
209209
probeStartTimestamp = _timeProvider.GetTimestamp();
210+
var stallStartTimestamp = isDirectProbe
211+
? _localSiloHealthMonitor.GetTimestamp()
212+
: 0;
210213
using var cancellation = new CancellationTokenSource(timeout, _timeProvider);
211214

212215
if (isDirectProbe)
213216
{
214217
// Probe the silo directly.
215-
probeResult = await this.ProbeDirectly(cancellation.Token).ConfigureAwait(false);
218+
probeResult = await this.ProbeDirectly(cancellation.Token, timeout, stallStartTimestamp).ConfigureAwait(false);
216219
}
217220
else
218221
{
@@ -321,22 +324,26 @@ internal static TimeSpan CalculateIndirectProbeTargetTimeout(TimeSpan timeout, i
321324
/// Probes the remote silo.
322325
/// </summary>
323326
/// <param name="cancellation">A token to cancel and fail the probe attempt.</param>
327+
/// <param name="probeTimeout">The timeout used for this probe, for local stall evaluation.</param>
328+
/// <param name="stallStartTimestamp">The start of the local stall collection interval.</param>
324329
/// <returns>The number of failed probes since the last successful probe.</returns>
325-
private async Task<ProbeResult> ProbeDirectly(CancellationToken cancellation)
330+
private async Task<ProbeResult> ProbeDirectly(CancellationToken cancellation, TimeSpan probeTimeout, long stallStartTimestamp)
326331
{
327332
var id = ++_nextProbeId;
328333
LogTraceGoingToSendPing(_log, id, TargetSiloAddress);
329334

330335
var roundTripTimer = TimeProviderValueStopwatch.StartNew(_timeProvider);
331336
ProbeResult probeResult;
332337
Exception? failureException;
338+
var probeTimedOut = false;
333339
try
334340
{
335341
await _prober.Probe(TargetSiloAddress, id, cancellation).WaitAsync(cancellation);
336342
failureException = null;
337343
}
338344
catch (OperationCanceledException exception)
339345
{
346+
probeTimedOut = cancellation.IsCancellationRequested;
340347
failureException = new OperationCanceledException(
341348
$"The ping attempt was cancelled after {roundTripTimer.GetElapsedTime(out _)}. Ping #{id}",
342349
exception);
@@ -346,7 +353,15 @@ private async Task<ProbeResult> ProbeDirectly(CancellationToken cancellation)
346353
failureException = exception;
347354
}
348355
var roundTripTime = roundTripTimer.GetElapsedTime(out _);
349-
356+
var stallDurationDuring = TimeSpan.Zero;
357+
if (probeTimedOut)
358+
{
359+
var probeEndTimestamp = _localSiloHealthMonitor.GetTimestamp();
360+
stallDurationDuring = await _localSiloHealthMonitor.GetStallDurationAsync(
361+
stallStartTimestamp,
362+
probeEndTimestamp,
363+
_stoppingCancellation.Token).ConfigureAwait(false);
364+
}
350365
if (failureException is null)
351366
{
352367
_messagingInstruments?.OnPingReplyReceived(TargetSiloAddress);
@@ -361,12 +376,22 @@ private async Task<ProbeResult> ProbeDirectly(CancellationToken cancellation)
361376
}
362377
else
363378
{
364-
_messagingInstruments?.OnPingReplyMissed(TargetSiloAddress);
365-
366-
var failedProbes = ++_failedProbes;
367-
LogWarningDidNotGetResponseForProbe(_log, failureException, id, TargetSiloAddress, roundTripTime, failedProbes);
379+
// Check if a local stall consumed a significant portion of the probe timeout.
380+
// If so, the local silo may have been unable to process the response in time,
381+
// so we treat this as an inconclusive result rather than a failure.
382+
if (probeTimedOut && stallDurationDuring >= probeTimeout.Multiply(0.25))
383+
{
384+
LogWarningProbeFailureDuringLocalStall(_log, id, TargetSiloAddress, roundTripTime, stallDurationDuring, _failedProbes);
385+
probeResult = ProbeResult.CreateDirect(_failedProbes, ProbeResultStatus.Unknown);
386+
}
387+
else
388+
{
389+
_messagingInstruments?.OnPingReplyMissed(TargetSiloAddress);
390+
var failedProbes = ++_failedProbes;
391+
LogWarningDidNotGetResponseForProbe(_log, failureException, id, TargetSiloAddress, roundTripTime, failedProbes);
368392

369-
probeResult = ProbeResult.CreateDirect(failedProbes, ProbeResultStatus.Failed);
393+
probeResult = ProbeResult.CreateDirect(failedProbes, ProbeResultStatus.Failed);
394+
}
370395
}
371396

372397
return probeResult;
@@ -536,5 +561,11 @@ public enum ProbeResultStatus
536561
Message = "Exception monitoring silo {SiloAddress}"
537562
)]
538563
private static partial void LogErrorExceptionMonitoringSilo(ILogger logger, Exception exception, SiloAddress siloAddress);
564+
565+
[LoggerMessage(
566+
Level = LogLevel.Warning,
567+
Message = "Probe #{Id} to silo {SiloAddress} failed after {Elapsed}, but local execution stalled for {StallDuration} during the probe. Treating as inconclusive. Consecutive failed probe count remains at {FailedProbeCount}."
568+
)]
569+
private static partial void LogWarningProbeFailureDuringLocalStall(ILogger logger, int id, SiloAddress siloAddress, TimeSpan elapsed, TimeSpan stallDuration, int failedProbeCount);
539570
}
540571
}

0 commit comments

Comments
 (0)