perf(comms+audit): close phase-2 residuals — direct ingest path, monotonic timeouts, synthetic probe, not-reporting set, cursor-exact audit pull
This commit is contained in:
@@ -331,6 +331,14 @@ public class AlarmSummaryRenderTests : BunitContext
|
||||
|
||||
public bool IsLive(int siteId) => _live;
|
||||
|
||||
/// <summary>
|
||||
/// The aggregator's own fan-out result. Settable so a test can prove the page renders
|
||||
/// the not-reporting list sourced from the live cache while it is serving the site.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> NotReporting { get; set; } = Array.Empty<string>();
|
||||
|
||||
public IReadOnlyList<string> GetNotReportingInstances(int siteId) => NotReporting;
|
||||
|
||||
public void PushAlarms(IReadOnlyList<AlarmStateChanged> alarms)
|
||||
{
|
||||
_current = alarms;
|
||||
|
||||
@@ -105,6 +105,8 @@ public class AlarmSummaryVirtualizeTests : BunitContext
|
||||
|
||||
public bool IsLive(int siteId) => false;
|
||||
|
||||
public IReadOnlyList<string> GetNotReportingInstances(int siteId) => Array.Empty<string>();
|
||||
|
||||
private sealed class NoOp : IDisposable
|
||||
{
|
||||
public void Dispose() { }
|
||||
|
||||
+54
-19
@@ -56,13 +56,13 @@ public class SharedAlarmSummaryServiceTests : IDisposable
|
||||
_provider = services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private SharedAlarmSummaryService CreateSut(TimeSpan liveCacheTtl) =>
|
||||
new(_provider.GetRequiredService<IServiceScopeFactory>(), _liveCache, liveCacheTtl, () => _now);
|
||||
private SharedAlarmSummaryService CreateSut() =>
|
||||
new(_provider.GetRequiredService<IServiceScopeFactory>(), _liveCache, () => _now);
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentCircuits_ShareOneFanOut()
|
||||
{
|
||||
var sut = CreateSut(TimeSpan.FromSeconds(60));
|
||||
var sut = CreateSut();
|
||||
|
||||
var results = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => sut.GetSiteAlarmsAsync(SiteId)));
|
||||
|
||||
@@ -73,7 +73,7 @@ public class SharedAlarmSummaryServiceTests : IDisposable
|
||||
[Fact]
|
||||
public async Task ColdLiveCache_RefreshesWithinThePageTick()
|
||||
{
|
||||
var sut = CreateSut(TimeSpan.FromSeconds(60));
|
||||
var sut = CreateSut();
|
||||
_liveCache.Live = false;
|
||||
|
||||
await sut.GetSiteAlarmsAsync(SiteId);
|
||||
@@ -86,22 +86,51 @@ public class SharedAlarmSummaryServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LiveCacheServing_WidensTheWindowToTheReconcileInterval()
|
||||
public async Task LiveCacheServing_SkipsTheFanOutEntirely()
|
||||
{
|
||||
var sut = CreateSut(TimeSpan.FromSeconds(60));
|
||||
// The aggregator's seed/reconcile already ran this exact fan-out and publishes both
|
||||
// halves of the answer, so the façade must not run a second one — not now, not after
|
||||
// any elapsed window (arch-review phase-2 residual #4).
|
||||
var sut = CreateSut();
|
||||
_liveCache.Live = true;
|
||||
_liveCache.NotReporting = new[] { "inst-silent" };
|
||||
_liveCache.Current = new[]
|
||||
{
|
||||
new AlarmStateChanged("inst-a", "A-alarm", AlarmState.Active, 500, T0),
|
||||
};
|
||||
|
||||
var first = await sut.GetSiteAlarmsAsync(SiteId);
|
||||
_now = T0.AddSeconds(61);
|
||||
var second = await sut.GetSiteAlarmsAsync(SiteId);
|
||||
|
||||
await _instanceRepo.DidNotReceive().GetInstancesBySiteIdAsync(SiteId, Arg.Any<CancellationToken>());
|
||||
await _snapshotClient.DidNotReceive().GetSnapshotAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
|
||||
// Both halves come from the cache: the rows so a liveness flip mid-call lands on
|
||||
// last-known state instead of a blank grid, and the not-reporting names the page shows.
|
||||
foreach (var result in new[] { first, second })
|
||||
{
|
||||
Assert.Equal("A-alarm", Assert.Single(result.Alarms).Alarm.AlarmName);
|
||||
Assert.Equal("inst-silent", Assert.Single(result.NotReportingInstances));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LiveCacheGoingCold_FallsBackToTheFanOut()
|
||||
{
|
||||
var sut = CreateSut();
|
||||
_liveCache.Live = true;
|
||||
|
||||
await sut.GetSiteAlarmsAsync(SiteId);
|
||||
_now = T0.AddSeconds(30);
|
||||
await sut.GetSiteAlarmsAsync(SiteId);
|
||||
await _instanceRepo.DidNotReceive().GetInstancesBySiteIdAsync(SiteId, Arg.Any<CancellationToken>());
|
||||
|
||||
// Aggregator died / stream degraded → the poll is the page's rebuild path again.
|
||||
_liveCache.Live = false;
|
||||
var result = await sut.GetSiteAlarmsAsync(SiteId);
|
||||
|
||||
// Live deltas own the rows; only the not-reporting list still comes from the
|
||||
// fan-out, so a 30s-old answer is fine and costs no second fan-out.
|
||||
await _instanceRepo.Received(1).GetInstancesBySiteIdAsync(SiteId, Arg.Any<CancellationToken>());
|
||||
|
||||
_now = T0.AddSeconds(61);
|
||||
await sut.GetSiteAlarmsAsync(SiteId);
|
||||
await _instanceRepo.Received(2).GetInstancesBySiteIdAsync(SiteId, Arg.Any<CancellationToken>());
|
||||
Assert.Single(result.Alarms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -113,7 +142,7 @@ public class SharedAlarmSummaryServiceTests : IDisposable
|
||||
_instanceRepo.GetInstancesBySiteIdAsync(otherSite, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Instance>());
|
||||
|
||||
var sut = CreateSut(TimeSpan.FromSeconds(60));
|
||||
var sut = CreateSut();
|
||||
|
||||
await sut.GetSiteAlarmsAsync(SiteId);
|
||||
await sut.GetSiteAlarmsAsync(otherSite);
|
||||
@@ -125,7 +154,7 @@ public class SharedAlarmSummaryServiceTests : IDisposable
|
||||
[Fact]
|
||||
public void PureMethods_MatchTheDirectImplementation()
|
||||
{
|
||||
var sut = CreateSut(TimeSpan.FromSeconds(60));
|
||||
var sut = CreateSut();
|
||||
var direct = new AlarmSummaryService(_instanceRepo, _siteRepo, _snapshotClient);
|
||||
var alarms = new List<AlarmStateChanged>
|
||||
{
|
||||
@@ -149,18 +178,24 @@ public class SharedAlarmSummaryServiceTests : IDisposable
|
||||
|
||||
public void Dispose() => _provider.Dispose();
|
||||
|
||||
/// <summary>Liveness-only stub — the façade consults nothing else on the live cache.</summary>
|
||||
/// <summary>
|
||||
/// Read-side stub: liveness, the published alarm snapshot, and the aggregator's
|
||||
/// not-reporting set — the three things the façade reads while the cache is serving a site.
|
||||
/// </summary>
|
||||
private sealed class FakeLiveCache : ISiteAlarmLiveCache
|
||||
{
|
||||
public bool Live { get; set; }
|
||||
public IReadOnlyList<AlarmStateChanged> Current { get; set; } = Array.Empty<AlarmStateChanged>();
|
||||
public IReadOnlyList<string> NotReporting { get; set; } = Array.Empty<string>();
|
||||
|
||||
public IDisposable Subscribe(int siteId, Action onChanged) => new NoOp();
|
||||
|
||||
public IReadOnlyList<AlarmStateChanged> GetCurrentAlarms(int siteId) =>
|
||||
Array.Empty<AlarmStateChanged>();
|
||||
public IReadOnlyList<AlarmStateChanged> GetCurrentAlarms(int siteId) => Current;
|
||||
|
||||
public bool IsLive(int siteId) => Live;
|
||||
|
||||
public IReadOnlyList<string> GetNotReportingInstances(int siteId) => NotReporting;
|
||||
|
||||
private sealed class NoOp : IDisposable
|
||||
{
|
||||
public void Dispose() { }
|
||||
|
||||
Reference in New Issue
Block a user