513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
186 lines
7.9 KiB
C#
186 lines
7.9 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.HealthMonitoring.Tests;
|
|
|
|
/// <summary>
|
|
/// Register row 33: the sustained-backlog anchor behind the Health dashboard's
|
|
/// "Replication backlog" badge.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The design constraint these tests exist to pin: a bare "backlog > threshold" badge is
|
|
/// unusable, because the daily `site_events` retention purge is CDC-captured like any other
|
|
/// write and jumps the backlog by the deleted batch size before it drains. That spike is
|
|
/// expected and documented. So the aggregator holds the instant the backlog FIRST rose above
|
|
/// the threshold, and the badge only appears once the configured dwell has elapsed — healthy
|
|
/// means the backlog comes back down, which no point-in-time check can express.
|
|
/// </remarks>
|
|
public sealed class LocalDbBacklogSustainedTests
|
|
{
|
|
private const long Threshold = 1000;
|
|
|
|
/// <summary>Verifies that a backlog above the threshold starts the above-threshold clock.</summary>
|
|
[Fact]
|
|
public void BacklogAboveThreshold_StartsTheClock()
|
|
{
|
|
var (aggregator, time) = NewAggregator();
|
|
var start = time.GetUtcNow();
|
|
|
|
aggregator.ProcessReport(Report("site-a", 1, backlog: Threshold + 1));
|
|
|
|
Assert.Equal(start, aggregator.GetSiteState("site-a")!.LocalDbBacklogAboveSince);
|
|
}
|
|
|
|
/// <summary>Verifies that a backlog that stays above the threshold keeps the original start instant.</summary>
|
|
[Fact]
|
|
public void BacklogStayingAbove_KeepsTheOriginalStartInstant()
|
|
{
|
|
// The window must be sustained, not sliding: if each above-threshold report reset the
|
|
// anchor, the dwell would never elapse and the badge could never appear at all.
|
|
var (aggregator, time) = NewAggregator();
|
|
var start = time.GetUtcNow();
|
|
|
|
aggregator.ProcessReport(Report("site-a", 1, backlog: Threshold + 1));
|
|
time.Advance(TimeSpan.FromMinutes(10));
|
|
aggregator.ProcessReport(Report("site-a", 2, backlog: Threshold + 5000));
|
|
|
|
Assert.Equal(start, aggregator.GetSiteState("site-a")!.LocalDbBacklogAboveSince);
|
|
}
|
|
|
|
/// <summary>Verifies that a backlog at or below the threshold clears the clock.</summary>
|
|
[Fact]
|
|
public void BacklogAtOrBelowThreshold_ClearsTheClock()
|
|
{
|
|
// The purge-burst exit path. A spike that drains must discard the anchor well before
|
|
// the dwell elapses, so the expected daily burst never badges the site.
|
|
var (aggregator, time) = NewAggregator();
|
|
|
|
aggregator.ProcessReport(Report("site-a", 1, backlog: Threshold + 50_000));
|
|
time.Advance(TimeSpan.FromMinutes(2));
|
|
aggregator.ProcessReport(Report("site-a", 2, backlog: 0));
|
|
|
|
Assert.Null(aggregator.GetSiteState("site-a")!.LocalDbBacklogAboveSince);
|
|
}
|
|
|
|
/// <summary>Verifies that a backlog exactly at the threshold does not start the clock.</summary>
|
|
[Fact]
|
|
public void BacklogExactlyAtThreshold_DoesNotStartTheClock()
|
|
{
|
|
// The comparison is strictly greater-than, so the threshold is the highest ACCEPTABLE
|
|
// depth rather than the lowest alarming one.
|
|
var (aggregator, _) = NewAggregator();
|
|
|
|
aggregator.ProcessReport(Report("site-a", 1, backlog: Threshold));
|
|
|
|
Assert.Null(aggregator.GetSiteState("site-a")!.LocalDbBacklogAboveSince);
|
|
}
|
|
|
|
/// <summary>Verifies that an unreplicated site reporting a null backlog never accumulates an anchor.</summary>
|
|
[Fact]
|
|
public void UnreplicatedSite_NeverAccumulatesAnAnchor()
|
|
{
|
|
// Rig site-b/site-c: a null backlog forever. Null means "no reading", never zero, so
|
|
// it must never start a clock — otherwise every unreplicated site would eventually be
|
|
// badged for a backlog it does not have.
|
|
var (aggregator, time) = NewAggregator();
|
|
|
|
aggregator.ProcessReport(Report("site-b", 1, backlog: null));
|
|
time.Advance(TimeSpan.FromHours(6));
|
|
aggregator.ProcessReport(Report("site-b", 2, backlog: null));
|
|
|
|
Assert.Null(aggregator.GetSiteState("site-b")!.LocalDbBacklogAboveSince);
|
|
}
|
|
|
|
/// <summary>Verifies that a null backlog in the middle of a climb leaves the anchor untouched.</summary>
|
|
[Fact]
|
|
public void NullBacklogMidClimb_LeavesTheAnchorUntouched()
|
|
{
|
|
// A failed poll on a replicated node reports null. That is not evidence the backlog
|
|
// drained, so it must not clear the clock — a node that goes blind mid-climb would
|
|
// otherwise silently reset its own dwell and never be badged.
|
|
var (aggregator, time) = NewAggregator();
|
|
var start = time.GetUtcNow();
|
|
|
|
aggregator.ProcessReport(Report("site-a", 1, backlog: Threshold + 1));
|
|
time.Advance(TimeSpan.FromMinutes(5));
|
|
aggregator.ProcessReport(Report("site-a", 2, backlog: null));
|
|
|
|
Assert.Equal(start, aggregator.GetSiteState("site-a")!.LocalDbBacklogAboveSince);
|
|
}
|
|
|
|
/// <summary>Verifies that the very first report for a new site can start the clock.</summary>
|
|
[Fact]
|
|
public void FirstReportForANewSite_CanStartTheClock()
|
|
{
|
|
// The registration path is a separate branch from the update path; a site whose very
|
|
// first report is already backlogged must not be missed.
|
|
var (aggregator, time) = NewAggregator();
|
|
var start = time.GetUtcNow();
|
|
|
|
aggregator.ProcessReport(Report("brand-new", 1, backlog: Threshold + 1));
|
|
|
|
Assert.Equal(start, aggregator.GetSiteState("brand-new")!.LocalDbBacklogAboveSince);
|
|
}
|
|
|
|
/// <summary>Verifies that the backlog threshold is configurable.</summary>
|
|
[Fact]
|
|
public void ThresholdIsConfigurable()
|
|
{
|
|
var (aggregator, _) = NewAggregator(threshold: 10);
|
|
|
|
aggregator.ProcessReport(Report("site-a", 1, backlog: 11));
|
|
|
|
Assert.NotNull(aggregator.GetSiteState("site-a")!.LocalDbBacklogAboveSince);
|
|
}
|
|
|
|
/// <summary>Verifies that a stale out-of-order report does not disturb the anchor.</summary>
|
|
[Fact]
|
|
public void StaleReport_DoesNotDisturbTheAnchor()
|
|
{
|
|
// Out-of-order reports are rejected wholesale by the sequence-number guard; the anchor
|
|
// must ride along with that rejection rather than being updated from stale data.
|
|
var (aggregator, time) = NewAggregator();
|
|
var start = time.GetUtcNow();
|
|
|
|
aggregator.ProcessReport(Report("site-a", 5, backlog: Threshold + 1));
|
|
time.Advance(TimeSpan.FromMinutes(5));
|
|
aggregator.ProcessReport(Report("site-a", 2, backlog: 0));
|
|
|
|
Assert.Equal(start, aggregator.GetSiteState("site-a")!.LocalDbBacklogAboveSince);
|
|
}
|
|
|
|
private static (CentralHealthAggregator Aggregator, TestTimeProvider Time) NewAggregator(
|
|
long threshold = Threshold)
|
|
{
|
|
var time = new TestTimeProvider(DateTimeOffset.UtcNow);
|
|
var opts = new HealthMonitoringOptions
|
|
{
|
|
OfflineTimeout = TimeSpan.FromSeconds(60),
|
|
LocalDbBacklogThreshold = threshold,
|
|
};
|
|
var aggregator = new CentralHealthAggregator(
|
|
Options.Create(opts), NullLogger<CentralHealthAggregator>.Instance, time);
|
|
return (aggregator, time);
|
|
}
|
|
|
|
private static SiteHealthReport Report(string siteId, long seq, long? backlog) =>
|
|
new(
|
|
SiteId: siteId,
|
|
SequenceNumber: seq,
|
|
ReportTimestamp: DateTimeOffset.UtcNow,
|
|
DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
|
|
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
|
|
ScriptErrorCount: 0,
|
|
AlarmEvaluationErrorCount: 0,
|
|
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
|
|
DeadLetterCount: 0,
|
|
DeployedInstanceCount: 0,
|
|
EnabledInstanceCount: 0,
|
|
DisabledInstanceCount: 0)
|
|
{
|
|
LocalDbOplogBacklog = backlog,
|
|
};
|
|
}
|