-
Notifications
You must be signed in to change notification settings - Fork 50
Add custom check for failing retention #5903
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| namespace ServiceControl.Persistence.EFCore.Infrastructure; | ||
|
|
||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using NServiceBus.CustomChecks; | ||
|
|
||
| interface IRetentionSweepHealth | ||
| { | ||
| string? GetFailureSummary(); | ||
| } | ||
|
|
||
| // Fails after any retention pass fails and recovers after the next fully successful sweep. | ||
| // The standard custom-check state-transition pipeline deduplicates repeated results. | ||
| class RetentionSweepCustomCheck(IRetentionSweepHealth retentionSweepHealth) | ||
| : CustomCheck("ServiceControl Retention", "ServiceControl Health", TimeSpan.FromMinutes(1)) | ||
| { | ||
| public override Task<CheckResult> PerformCheck(CancellationToken cancellationToken = default) | ||
| { | ||
| var failureSummary = retentionSweepHealth.GetFailureSummary(); | ||
|
|
||
| return Task.FromResult(failureSummary is null | ||
| ? CheckResult.Pass | ||
| : CheckResult.Failed($"Retention processing has failures. Last failure per entity: {failureSummary}")); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -24,13 +24,15 @@ public class RetentionSweeper( | |||
| IBodyStoragePersistence bodyStorage, | ||||
| RetentionMetrics metrics, | ||||
| EFPersisterSettings settings, | ||||
| IHostApplicationLifetime hostApplicationLifetime) : BackgroundService, IRetentionSweeper | ||||
| IHostApplicationLifetime hostApplicationLifetime) : BackgroundService, IRetentionSweeper, IRetentionSweepHealth | ||||
| { | ||||
| const int BatchSize = 1000; | ||||
| static readonly TimeSpan Interval = TimeSpan.FromHours(1); | ||||
| static readonly TimeSpan InitialDelay = TimeSpan.FromMinutes(1); | ||||
| static readonly TimeSpan BatchPause = TimeSpan.FromSeconds(1); | ||||
|
|
||||
| readonly Dictionary<RetentionEntity, string> failures = []; | ||||
|
|
||||
| // Single-flight guard shared by the hourly timer path and the manual API path so two sweeps | ||||
| // never overlap. Precedent: ExternalIntegrationRequestsDataStore.drainLock. | ||||
| readonly SemaphoreSlim sweepLock = new(1, 1); | ||||
|
|
@@ -129,6 +131,32 @@ async Task SweepWithoutAcquiringLock() | |||
|
|
||||
| public RetentionSweepCurrentStatus GetStatus() => new(isRunning, lastStartedAt, lastFinishedAt, lastErrorCutoff, lastEventsCutoff); | ||||
|
|
||||
| internal void RecordFailure(RetentionEntity entity, string reason) | ||||
| { | ||||
| lock (failures) | ||||
| { | ||||
| failures[entity] = reason; | ||||
| } | ||||
| } | ||||
|
|
||||
| internal (RetentionEntity Entity, string Reason)[] GetActiveFailures() | ||||
| { | ||||
| lock (failures) | ||||
| { | ||||
| return failures.Select(failure => (failure.Key, failure.Value)).ToArray(); | ||||
| } | ||||
| } | ||||
|
|
||||
| string? IRetentionSweepHealth.GetFailureSummary() | ||||
| { | ||||
| lock (failures) | ||||
| { | ||||
| return failures.Count == 0 | ||||
| ? null | ||||
| : string.Join("; ", failures.Select(failure => $"{failure.Key}: {failure.Value}")); | ||||
| } | ||||
| } | ||||
|
|
||||
| async Task Sweep(DateTime? errorCutoff, DateTime? eventsCutoff, bool pace, CancellationToken cancellationToken) | ||||
| { | ||||
| await sweepLock.WaitAsync(cancellationToken); | ||||
|
|
@@ -152,28 +180,38 @@ async Task Sweep(DateTime? errorCutoff, DateTime? eventsCutoff, bool pace, Cance | |||
| // manual background path (which already holds the lock) share one implementation. | ||||
| async Task SweepBody(DateTime? errorCutoff, DateTime? eventsCutoff, bool pace, CancellationToken cancellationToken) | ||||
| { | ||||
| await RunPass(RetentionEntity.FailedMessages, token => SweepFailedMessages(pace, errorCutoff, token), cancellationToken); | ||||
| await RunPass(RetentionEntity.EventLog, token => SweepEventLogItems(pace, eventsCutoff, token), cancellationToken); | ||||
| await RunPass(RetentionEntity.GroupComments, SweepOrphanedGroupComments, cancellationToken); | ||||
| var failedMessagesSucceeded = await RunPass(RetentionEntity.FailedMessages, token => SweepFailedMessages(pace, errorCutoff, token), cancellationToken); | ||||
| var eventLogSucceeded = await RunPass(RetentionEntity.EventLog, token => SweepEventLogItems(pace, eventsCutoff, token), cancellationToken); | ||||
| var groupCommentsSucceeded = await RunPass(RetentionEntity.GroupComments, SweepOrphanedGroupComments, cancellationToken); | ||||
|
|
||||
| if (failedMessagesSucceeded && eventLogSucceeded && groupCommentsSucceeded) | ||||
| { | ||||
| lock (failures) | ||||
| { | ||||
| failures.Clear(); | ||||
| } | ||||
| } | ||||
|
Comment on lines
+187
to
+193
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we be clearing individual failures as they become successful rather than waiting for all 3? If 1 is fixed, it'l still show all 3 failing until all 3 are resolved. Take a look at |
||||
| } | ||||
|
|
||||
| // Each pass is isolated so one failing kind of row does not stop the others from being | ||||
| // reclaimed, and so the metrics report an outcome for every pass on every run. | ||||
| async Task RunPass(RetentionEntity entity, Func<CancellationToken, Task> pass, CancellationToken cancellationToken) | ||||
| async Task<bool> RunPass(RetentionEntity entity, Func<CancellationToken, Task> pass, CancellationToken cancellationToken) | ||||
| { | ||||
| using var cycle = metrics.BeginCycle(entity, cancellationToken); | ||||
|
|
||||
| try | ||||
| { | ||||
| await pass(cancellationToken); | ||||
|
|
||||
| cycle.Complete(); | ||||
| return true; | ||||
| } | ||||
| #pragma warning disable PS0019 // The filter already excludes OperationCanceledException, so | ||||
| // cancellation propagates; PS0019 only recognises a cancellationToken guard. | ||||
| catch (Exception ex) when (ex is not OperationCanceledException) | ||||
| { | ||||
| logger.LogError(ex, "Error during the {RetentionEntity} retention pass", entity); | ||||
| RecordFailure(entity, ex.Message); | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We are in essence displaying
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you have any suggestions to what action we could give them? The main thing that I'd see this failing on would either be deadlocks or timeouts from an overloaded DB, a completely unreachable DB takes ServicePulse offline equivalent to the SC instance disappearing.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Take a look at the pattern used in the below. Could we use this to move some of the failure logic out of the retention sweeper?
|
||||
| return false; | ||||
| } | ||||
| #pragma warning restore PS0019 | ||||
| } | ||||
|
|
||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| ServiceControl Health: ServiceControl Retention | ||
| Storage space: ServiceControl body storage |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| ServiceControl Health: ServiceControl Retention | ||
| Storage space: ServiceControl body storage |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| namespace ServiceControl.Persistence.Tests; | ||
|
|
||
| using System; | ||
| using System.Linq; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using Microsoft.Extensions.Logging.Abstractions; | ||
| using NServiceBus.CustomChecks; | ||
| using NUnit.Framework; | ||
| using ServiceControl.Contracts.CustomChecks; | ||
| using ServiceControl.CustomChecks; | ||
| using ServiceControl.Infrastructure.DomainEvents; | ||
| using ServiceControl.Operations; | ||
| using ServiceControl.Persistence.EFCore.Infrastructure; | ||
| using ServiceControl.Persistence.EFCore.Infrastructure.Metrics; | ||
|
|
||
| class RetentionSweepCustomCheckTests : PersistenceTestBase | ||
| { | ||
| RetentionSweeper Sweeper => ServiceProvider.GetRequiredService<RetentionSweeper>(); | ||
|
|
||
| RetentionSweepCustomCheck Check => | ||
| ServiceProvider.GetServices<ICustomCheck>().OfType<RetentionSweepCustomCheck>().Single(); | ||
|
|
||
| [Test] | ||
| public async Task Check_initially_passes() | ||
| { | ||
| var result = await Check.PerformCheck(); | ||
| Assert.That(result, Is.EqualTo(CheckResult.Pass)); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task Check_fails_after_a_retention_failure() | ||
| { | ||
| Sweeper.RecordFailure(RetentionEntity.FailedMessages, "db timeout"); | ||
|
|
||
| var result = await Check.PerformCheck(); | ||
|
|
||
| using (Assert.EnterMultipleScope()) | ||
| { | ||
| Assert.That(result.HasFailed, Is.True); | ||
| Assert.That(result.FailureReason, Does.Contain("FailedMessages")); | ||
| Assert.That(result.FailureReason, Does.Contain("db timeout")); | ||
| } | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task Check_passes_after_a_fully_successful_sweep() | ||
| { | ||
| Sweeper.RecordFailure(RetentionEntity.FailedMessages, "db timeout"); | ||
|
|
||
| await Sweeper.SweepNow(); | ||
|
|
||
| var result = await Check.PerformCheck(); | ||
| Assert.That(result, Is.EqualTo(CheckResult.Pass)); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task Check_does_not_expire_with_time() | ||
| { | ||
| Sweeper.RecordFailure(RetentionEntity.FailedMessages, "db timeout"); | ||
| AdvanceClock(TimeSpan.FromHours(2)); | ||
|
|
||
| var result = await Check.PerformCheck(); | ||
| Assert.That(result.HasFailed, Is.True); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task Multiple_failing_entities_are_represented() | ||
| { | ||
| Sweeper.RecordFailure(RetentionEntity.FailedMessages, "body delete failed"); | ||
| Sweeper.RecordFailure(RetentionEntity.EventLog, "batch delete timeout"); | ||
|
|
||
| var result = await Check.PerformCheck(); | ||
|
|
||
| using (Assert.EnterMultipleScope()) | ||
| { | ||
| Assert.That(result.HasFailed, Is.True); | ||
| Assert.That(result.FailureReason, Does.Contain("FailedMessages")); | ||
| Assert.That(result.FailureReason, Does.Contain("EventLog")); | ||
| Assert.That(result.FailureReason, Does.Contain("body delete failed")); | ||
| Assert.That(result.FailureReason, Does.Contain("batch delete timeout")); | ||
| } | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task Repeated_failed_results_raise_one_state_change_event() | ||
| { | ||
| Sweeper.RecordFailure(RetentionEntity.FailedMessages, "db timeout"); | ||
|
|
||
| var result = await Check.PerformCheck(); | ||
| var domainEvents = (FakeDomainEvents)ServiceProvider.GetRequiredService<IDomainEvents>(); | ||
| var processor = new CustomCheckResultProcessor(domainEvents, CustomChecks, NullLogger<CustomCheckResultProcessor>.Instance); | ||
| var detail = new CustomCheckDetail | ||
| { | ||
| Category = "ServiceControl Health", | ||
| CustomCheckId = "ServiceControl Retention", | ||
| HasFailed = result.HasFailed, | ||
| FailureReason = result.FailureReason, | ||
| ReportedAt = Now, | ||
| OriginatingEndpoint = new EndpointDetails | ||
| { | ||
| Host = "localhost", | ||
| HostId = Guid.NewGuid(), | ||
| Name = "ServiceControl" | ||
| } | ||
| }; | ||
|
|
||
| await processor.ProcessResult(detail); | ||
| await processor.ProcessResult(detail); | ||
|
|
||
| Assert.That(domainEvents.RaisedEvents.OfType<CustomCheckFailed>().Count(), Is.EqualTo(1)); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will the custom check pass if the retention never starts? Could we also raise a failure for this as well?