feat(alarms): feed-level snapshot_status truncation frame on StreamAlarms

This commit is contained in:
Joseph Doherty
2026-08-17 07:16:51 -04:00
parent 094f2ffee4
commit fccf75324b
8 changed files with 920 additions and 197 deletions
@@ -624,11 +624,13 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// at-least-once: consumers must still treat alarm state idempotently — apply a transition as
// "set the alarm to this state", never as an increment or a toggle.
//
// Truncation (`snapshotTruncated`) needs no special handling here, and that is worth saying
// Truncation (`snapshotTruncated`) needs no per-alarm handling here, and that is worth saying
// because the obvious worry — a capped fetch reading as a wave of Clears — is answered one
// level down. The worker merges rather than replaces its retained snapshot on a capped fetch,
// so the set arriving here still carries the alarms the capped reply had no room to mention.
// The flag is therefore only recorded, for the operator-facing completeness caveat.
// The flag is set-level status, not a delta: it is recorded, and a CHANGE of verdict is pushed
// to the feed as a snapshot_status frame (SetSnapshotTruncated) so live consumers learn the
// completeness caveat without polling QueryActiveAlarms.
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots, bool snapshotTruncated)
{
Dictionary<string, ActiveAlarmSnapshot> next = new(StringComparer.Ordinal);
@@ -688,11 +690,30 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
_alarms[incoming.Key] = incoming.Value;
}
_snapshotTruncated = snapshotTruncated;
SetSnapshotTruncated(snapshotTruncated);
_currentAlarmsProjection = null;
}
}
// Caller holds _sync. Records the truncation verdict and, on a CHANGE of verdict, pushes the
// feed-level snapshot_status frame. Edge-triggered rather than per-reconcile: a status frame
// repeated every reconcile interval is one consumers learn to ignore. The verdict describes the
// whole cached set, not one alarm, so — like provider status — it goes to every subscriber
// regardless of alarm-filter prefix.
private void SetSnapshotTruncated(bool truncated)
{
if (_snapshotTruncated == truncated)
{
return;
}
_snapshotTruncated = truncated;
BroadcastToAll(new AlarmFeedMessage
{
SnapshotStatus = new AlarmSnapshotStatus { Truncated = truncated },
});
}
// Caller holds _sync. Pushes a feed message to every matching subscriber;
// a subscriber that has fallen behind is completed with an error and dropped.
private void Broadcast(AlarmFeedMessage message, string reference)
@@ -738,8 +759,10 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
_alarms.Clear();
// The truncation verdict describes the cache generation being discarded, so it goes
// with it. Carrying it across a monitor restart would caveat an empty set as "may be
// incomplete" on evidence from a session that no longer exists.
_snapshotTruncated = false;
// incomplete" on evidence from a session that no longer exists. Dropping a truncated
// verdict IS a completeness change, and feed subscribers outlive the monitor's worker
// session, so this routes through the edge path and they see the clearing frame.
SetSnapshotTruncated(false);
_currentAlarmsProjection = null;
}
}
@@ -761,13 +784,16 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
ActiveAlarmSnapshot[] snapshot;
AlarmProviderStatus providerStatus;
bool snapshotTruncated;
lock (_sync)
{
// Register before snapshotting under the same lock so neither a
// transition nor a provider-mode change can slip between the snapshot
// and the live stream.
// and the live stream. The truncation verdict is read here too, so the
// caveat and the set it qualifies are a consistent pair.
_subscribers.Add(subscriber);
providerStatus = BuildProviderStatus();
snapshotTruncated = _snapshotTruncated;
snapshot = _alarms.Values
.Where(alarm => prefix.Length == 0
|| alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal))
@@ -781,6 +807,15 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// learns the mode (and whether the feed is degraded) before any alarms.
yield return new AlarmFeedMessage { ProviderStatus = providerStatus };
// Then the completeness caveat, BEFORE the cached snapshot it qualifies: a consumer
// applying the snapshot as it streams needs to know whether the set may be missing
// alarms while it applies it, not after. Unconditional — an explicit false is what
// separates "the set is complete" from "this gateway never sends the frame".
yield return new AlarmFeedMessage
{
SnapshotStatus = new AlarmSnapshotStatus { Truncated = snapshotTruncated },
};
foreach (ActiveAlarmSnapshot alarm in snapshot)
{
yield return new AlarmFeedMessage { ActiveAlarm = alarm };