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
@@ -30,6 +30,7 @@ namespace ZB.MOM.WW.ScadaBridge.Communication;
public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
{
private static readonly IReadOnlyList<AlarmStateChanged> Empty = Array.Empty<AlarmStateChanged>();
private static readonly IReadOnlyList<string> EmptyNames = Array.Empty<string>();
private readonly IServiceProvider _serviceProvider;
private readonly CommunicationService _communicationService;
@@ -135,6 +136,13 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
return _sites.TryGetValue(siteId, out var entry) && entry.HasPublished && entry.StreamLive;
}
/// <inheritdoc />
public IReadOnlyList<string> GetNotReportingInstances(int siteId)
{
lock (_lock)
return _sites.TryGetValue(siteId, out var entry) ? entry.NotReporting : EmptyNames;
}
// ── Subscriber teardown ─────────────────────────────────────────────────────
private void Unsubscribe(Subscription subscription)
@@ -332,6 +340,15 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
/// <see cref="DebugViewSnapshot.InstanceNotFound"/> contributes no rows rather than
/// failing the whole seed (mirrors <c>AlarmSummaryService</c>). Re-enumerates instances
/// each call so a reconcile picks up enable/disable/deploy/delete drift.
/// <para>
/// It also records WHICH instances failed to answer into
/// <see cref="SiteEntry.NotReporting"/>, published through
/// <see cref="ISiteAlarmLiveCache.GetNotReportingInstances"/>. That set was previously
/// computed and thrown away here, forcing the Alarm Summary page to run a second, identical
/// fan-out through <c>AlarmSummaryService</c> purely to rebuild it. Same classification as
/// that service (<c>InstanceNotFound</c> or any non-cancellation fault), same
/// ordinal-ignore-case ordering, so the page renders identically from either source.
/// </para>
/// </summary>
private async Task<IReadOnlyList<AlarmStateChanged>> FanOutSnapshotsAsync(int siteId, CancellationToken ct)
{
@@ -343,7 +360,10 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
var siteRepo = scope.ServiceProvider.GetRequiredService<ISiteRepository>();
var site = await siteRepo.GetSiteByIdAsync(siteId, ct);
if (site is null)
{
PublishNotReporting(siteId, EmptyNames);
return Empty;
}
siteIdentifier = site.SiteIdentifier;
var instanceRepo = scope.ServiceProvider.GetRequiredService<ITemplateEngineRepository>();
@@ -355,25 +375,48 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
}
if (enabledInstanceNames.Count == 0)
{
PublishNotReporting(siteId, EmptyNames);
return Empty;
}
var rows = new ConcurrentBag<AlarmStateChanged>();
var notReporting = new ConcurrentBag<string>();
using var gate = new SemaphoreSlim(
Math.Max(1, _options.LiveAlarmCacheSeedConcurrency),
Math.Max(1, _options.LiveAlarmCacheSeedConcurrency));
var fetches = enabledInstanceNames.Select(name =>
FetchInstanceSnapshotAsync(siteIdentifier, name, gate, rows, ct));
FetchInstanceSnapshotAsync(siteIdentifier, name, gate, rows, notReporting, ct));
await Task.WhenAll(fetches);
PublishNotReporting(
siteId,
notReporting.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList());
return rows.ToList();
}
/// <summary>
/// Stores the fan-out's not-reporting set on the site entry. Silently no-ops when the entry
/// has been torn down mid-fan-out (last viewer left), matching every other late-write path
/// in this service.
/// </summary>
private void PublishNotReporting(int siteId, IReadOnlyList<string> notReporting)
{
lock (_lock)
{
if (_sites.TryGetValue(siteId, out var entry))
entry.NotReporting = notReporting;
}
}
private async Task FetchInstanceSnapshotAsync(
string siteIdentifier,
string instanceUniqueName,
SemaphoreSlim gate,
ConcurrentBag<AlarmStateChanged> rows,
ConcurrentBag<string> notReporting,
CancellationToken ct)
{
await gate.WaitAsync(ct);
@@ -387,7 +430,10 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
instanceUniqueName, Guid.NewGuid().ToString("N"), AlarmsOnly: true);
var snapshot = await _communicationService.RequestDebugSnapshotAsync(siteIdentifier, request, ct);
if (snapshot.InstanceNotFound)
{
notReporting.Add(instanceUniqueName);
return;
}
foreach (var alarm in snapshot.AlarmStates)
rows.Add(alarm);
@@ -398,7 +444,9 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
}
catch
{
// Degrade this one instance to "no rows" rather than failing the whole seed.
// Degrade this one instance to "no rows, not reporting" rather than failing the
// whole seed. Same rule as AlarmSummaryService.FetchInstanceAsync.
notReporting.Add(instanceUniqueName);
}
finally
{
@@ -474,6 +522,9 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
entry.HasPublished = false;
entry.StreamLive = false;
entry.Current = Empty;
// A dead aggregator's last fan-out result is stale too — clear it so the page's
// poll fallback (now authoritative again) owns the not-reporting list outright.
entry.NotReporting = EmptyNames;
if (entry.Subscribers.Count > 0 && !entry.Starting)
{
@@ -509,6 +560,13 @@ public sealed class SiteAlarmLiveCacheService : ISiteAlarmLiveCache
/// <summary>True once the aggregator has seeded and published at least once.</summary>
public bool HasPublished { get; set; }
/// <summary>
/// Enabled instances that failed to answer the most recent snapshot fan-out, ordered by
/// name. Refreshed on every seed/reconcile (see <c>FanOutSnapshotsAsync</c>) and cleared
/// when liveness resets, so a dead aggregator never leaves a stale list behind.
/// </summary>
public IReadOnlyList<string> NotReporting { get; set; } = EmptyNames;
/// <summary>
/// Liveness of the aggregator's site-wide gRPC stream as of the last publish. Both
/// this and <see cref="HasPublished"/> must hold for <c>IsLive</c>: a published cache