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:
Joseph Doherty
2026-08-14 21:38:23 -04:00
parent 4cd1441984
commit a5882753dd
38 changed files with 1254 additions and 443 deletions
@@ -331,8 +331,10 @@
return;
}
// _notReporting is the poll's unique authority — the alarm-only live cache
// cannot compute it — so it is always refreshed.
// _notReporting always comes from this call, live or not. While the cache is
// serving the site the façade sources it from the aggregator's own fan-out (no
// second fan-out is run); while it is cold the poll computes it. Either way the
// shape and ordering are identical.
_notReporting = result.NotReportingInstances;
// While the cache is live, the live deltas own the row set: a poll whose fan-out
@@ -154,9 +154,11 @@ public sealed class AlarmSummaryService : IAlarmSummaryService
.ThenBy(r => r.Alarm.AlarmName, StringComparer.OrdinalIgnoreCase)
.ToList();
// The live cache is alarm-only, so it cannot enumerate "not reporting"
// instances — left empty here; the periodic poll (GetSiteAlarmsAsync)
// remains the authority for that list.
// Alarm rows alone cannot name the instances that failed to answer, so this pure
// flattening leaves the list empty. The names come from the fan-out that produced the
// snapshot: while the live cache is serving a site, SharedAlarmSummaryService pairs
// these rows with ISiteAlarmLiveCache.GetNotReportingInstances (the aggregator's own
// seed/reconcile result); while it is cold, GetSiteAlarmsAsync's poll supplies both.
return new AlarmSummaryResult(orderedRows, Array.Empty<string>());
}
@@ -1,6 +1,5 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Communication;
@@ -18,12 +17,25 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// viewers are watching.
/// </para>
/// <para>
/// <b>The freshness window follows the live cache.</b> While
/// <see cref="ISiteAlarmLiveCache.IsLive"/> is true the page deliberately ignores the poll's
/// alarm rows (the live deltas own them — arch-review R2 N5), so the only thing the poll
/// still supplies is the not-reporting list, and the window widens to the aggregator's own
/// reconcile interval. While the cache is cold the poll is the page's full-rebuild safety
/// net, so the window stays just under the page's 15s tick and every tick gets fresh data.
/// <b>While the live cache is serving the site there is NO fan-out at all.</b> The page already
/// ignores the poll's alarm rows in that state (the live deltas own them — arch-review R2 N5),
/// and the aggregator's own seed/reconcile fan-out now publishes the not-reporting set through
/// <see cref="ISiteAlarmLiveCache.GetNotReportingInstances"/> — the one thing the poll used to
/// still supply. So the live path is answered entirely from the cache: rows flattened from
/// <see cref="ISiteAlarmLiveCache.GetCurrentAlarms"/> through the same
/// <c>AlarmSummaryService.BuildFromLiveAlarmsCore</c> the page's live subscription uses, plus the
/// cached not-reporting names. One fan-out per site per reconcile window, run by the aggregator,
/// instead of two.
/// </para>
/// <para>
/// The rows are returned rather than left empty on purpose: liveness can flip to false in the
/// window between this call and the page's own <c>IsLive</c> re-check, and the page would then
/// adopt whatever came back. Handing it the live snapshot makes that race land on last-known
/// state instead of a blank grid.
/// </para>
/// <para>
/// While the cache is cold the poll is the page's full-rebuild safety net, so it still fans out,
/// memoized with a window just under the page's 15s tick so every tick gets fresh data.
/// </para>
/// </summary>
public sealed class SharedAlarmSummaryService : IAlarmSummaryService
@@ -36,7 +48,6 @@ public sealed class SharedAlarmSummaryService : IAlarmSummaryService
private readonly IServiceScopeFactory _scopeFactory;
private readonly ISiteAlarmLiveCache _liveCache;
private readonly TimeSpan _liveCacheTtl;
private readonly Func<DateTimeOffset>? _clock;
private readonly ConcurrentDictionary<int, SingleFlightMemo<AlarmSummaryResult>> _bySite = new();
@@ -44,36 +55,34 @@ public sealed class SharedAlarmSummaryService : IAlarmSummaryService
/// <summary>
/// Initializes the shared alarm summary façade.
/// </summary>
/// <remarks>
/// There is no live-cache freshness window any more: the live path reads the aggregator's
/// cache directly and never fans out, so the only window left is <see cref="ColdCacheTtl"/>.
/// The reconcile interval this ctor used to read from <c>CommunicationOptions</c> is now
/// implicit — it IS the cadence at which the aggregator refreshes what the live path returns.
/// </remarks>
/// <param name="scopeFactory">Opens a fresh DI scope per fan-out (fresh repositories, off any circuit scope).</param>
/// <param name="liveCache">The shared live alarm cache, consulted only for its per-site liveness.</param>
/// <param name="options">Communication options; supplies the aggregator reconcile interval.</param>
/// <param name="liveCache">The shared live alarm cache: liveness, alarm rows, and not-reporting names.</param>
public SharedAlarmSummaryService(
IServiceScopeFactory scopeFactory,
ISiteAlarmLiveCache liveCache,
IOptions<CommunicationOptions> options)
: this(scopeFactory, liveCache, (options ?? throw new ArgumentNullException(nameof(options)))
.Value.LiveAlarmCacheReconcileInterval, clock: null)
ISiteAlarmLiveCache liveCache)
: this(scopeFactory, liveCache, clock: null)
{
}
/// <summary>
/// Test seam: same façade with an explicit live-cache window and clock.
/// Test seam: same façade with an explicit clock.
/// </summary>
/// <param name="scopeFactory">Opens a fresh DI scope per fan-out.</param>
/// <param name="liveCache">The shared live alarm cache.</param>
/// <param name="liveCacheTtl">Freshness window used while the live cache is serving the site.</param>
/// <param name="clock">Clock used for freshness.</param>
internal SharedAlarmSummaryService(
IServiceScopeFactory scopeFactory,
ISiteAlarmLiveCache liveCache,
TimeSpan liveCacheTtl,
Func<DateTimeOffset>? clock)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_liveCache = liveCache ?? throw new ArgumentNullException(nameof(liveCache));
// Never shorter than the cold window — a misconfigured reconcile interval must not
// silently make the memo useless.
_liveCacheTtl = liveCacheTtl > ColdCacheTtl ? liveCacheTtl : ColdCacheTtl;
_clock = clock;
}
@@ -81,17 +90,25 @@ public sealed class SharedAlarmSummaryService : IAlarmSummaryService
public Task<AlarmSummaryResult> GetSiteAlarmsAsync(
int siteId, CancellationToken cancellationToken = default)
{
// Live: answer from the shared aggregator's cache and skip the fan-out entirely. Not
// memoized — there is nothing to amortize, and reading the cache directly keeps the
// answer as fresh as the aggregator itself.
if (_liveCache.IsLive(siteId))
{
var live = AlarmSummaryService.BuildFromLiveAlarmsCore(_liveCache.GetCurrentAlarms(siteId));
return Task.FromResult(new AlarmSummaryResult(
live.Alarms, _liveCache.GetNotReportingInstances(siteId)));
}
var memo = _bySite.GetOrAdd(
siteId,
_ => new SingleFlightMemo<AlarmSummaryResult>(ColdCacheTtl, _clock));
var ttl = _liveCache.IsLive(siteId) ? _liveCacheTtl : ColdCacheTtl;
return memo.GetAsync(
() => FanOutAsync(siteId),
forceRefresh: false,
cancellationToken,
ttlOverride: ttl);
ttlOverride: ColdCacheTtl);
}
/// <inheritdoc/>