refactor(kpi): K4/K10/K12 review fixups — test data-race + faulted-tick liveness, dead-branch/unused removal, NaN-guard assertions, value clamp + doc
This commit is contained in:
@@ -64,6 +64,39 @@ public class KpiHistoryRecorderActorTests : TestKit
|
||||
throw new InvalidOperationException("simulated source failure");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A source that throws on the first <see cref="CollectAsync"/> call and returns a
|
||||
/// healthy sample on every subsequent call. Used to drive a faulted first tick followed
|
||||
/// by a healthy second tick on the <em>same</em> actor instance.
|
||||
/// </summary>
|
||||
private sealed class ThrowOnceSource : IKpiSampleSource
|
||||
{
|
||||
private int _callCount;
|
||||
|
||||
public string Source => KpiSources.SiteCallAudit;
|
||||
|
||||
public Task<IReadOnlyList<KpiSample>> CollectAsync(
|
||||
DateTime capturedAtUtc, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Interlocked.Increment(ref _callCount) == 1)
|
||||
throw new InvalidOperationException("simulated first-call source failure");
|
||||
|
||||
IReadOnlyList<KpiSample> samples = new[]
|
||||
{
|
||||
new KpiSample
|
||||
{
|
||||
Source = Source,
|
||||
Metric = "RecoveredSample",
|
||||
Scope = KpiScopes.Global,
|
||||
ScopeKey = null,
|
||||
Value = 1,
|
||||
CapturedAtUtc = capturedAtUtc,
|
||||
},
|
||||
};
|
||||
return Task.FromResult(samples);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recording repository fake. Captures the samples handed to
|
||||
/// <see cref="RecordSamplesAsync"/> and the cut-off handed to
|
||||
@@ -73,13 +106,19 @@ public class KpiHistoryRecorderActorTests : TestKit
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<KpiSample> _recorded = new();
|
||||
private DateTime? _purgeCutoff;
|
||||
|
||||
public IReadOnlyList<KpiSample> Recorded
|
||||
{
|
||||
get { lock (_gate) { return _recorded.ToArray(); } }
|
||||
}
|
||||
|
||||
public DateTime? PurgeCutoff { get; private set; }
|
||||
// PurgeOlderThanAsync runs on a threadpool thread; guard the field with
|
||||
// the same _gate lock used by _recorded so test-thread reads are race-free.
|
||||
public DateTime? PurgeCutoff
|
||||
{
|
||||
get { lock (_gate) { return _purgeCutoff; } }
|
||||
}
|
||||
|
||||
public Task RecordSamplesAsync(
|
||||
IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default)
|
||||
@@ -98,7 +137,7 @@ public class KpiHistoryRecorderActorTests : TestKit
|
||||
|
||||
public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default)
|
||||
{
|
||||
PurgeCutoff = before;
|
||||
lock (_gate) { _purgeCutoff = before; }
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
@@ -188,27 +227,28 @@ public class KpiHistoryRecorderActorTests : TestKit
|
||||
[Fact]
|
||||
public void FaultedTick_DoesNotCrashActor_AndSubsequentTickStillRuns()
|
||||
{
|
||||
// ThrowOnceSource throws on the first CollectAsync call and returns a healthy
|
||||
// sample on every subsequent call. This lets us send two ticks to the SAME
|
||||
// actor instance and verify that:
|
||||
// • The first tick (faulted source) records nothing but does not crash the actor.
|
||||
// • The second tick reaches the same actor and records the recovered sample,
|
||||
// proving the singleton's message loop is still alive after a faulted pass.
|
||||
var repository = new RecordingRepository();
|
||||
// A pass containing ONLY a throwing source records nothing but must not crash the
|
||||
// actor; a later healthy tick proves the singleton survived.
|
||||
var sp = BuildServiceProvider(repository, new ThrowingSource());
|
||||
var sp = BuildServiceProvider(repository, new ThrowOnceSource());
|
||||
var actor = CreateActor(sp);
|
||||
|
||||
// First tick: the only source throws — caught per-source, nothing written, actor lives.
|
||||
// First tick: source throws on first call — caught per-source, nothing written, actor lives.
|
||||
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
|
||||
AwaitAssert(
|
||||
() => Assert.Empty(repository.Recorded),
|
||||
duration: TimeSpan.FromSeconds(1),
|
||||
duration: TimeSpan.FromSeconds(2),
|
||||
interval: TimeSpan.FromMilliseconds(50));
|
||||
|
||||
// Second tick on a fresh actor backed by a healthy source proves the message loop is
|
||||
// still alive and the recorder still records after a faulted pass on the prior actor.
|
||||
var healthyRepo = new RecordingRepository();
|
||||
var healthySp = BuildServiceProvider(healthyRepo, new HealthySource());
|
||||
var healthyActor = CreateActor(healthySp);
|
||||
healthyActor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
|
||||
// Second tick to the SAME actor: source now returns a healthy sample.
|
||||
// AwaitAssert confirms the actor processed the message and recorded it.
|
||||
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
|
||||
AwaitAssert(
|
||||
() => Assert.Equal(2, healthyRepo.Recorded.Count),
|
||||
() => Assert.Single(repository.Recorded),
|
||||
duration: TimeSpan.FromSeconds(3),
|
||||
interval: TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user