From f7ccfd678e8db1f1e7b84d63bf87a62346a29956 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 13 Jun 2026 10:09:10 -0400 Subject: [PATCH] server(alarms): watch-list resolver merging GR discovery + config override --- .../Alarms/AlarmWatchListResolver.cs | 158 +++++++++++ .../Alarms/IAlarmWatchListResolver.cs | 28 ++ .../Alarms/AlarmWatchListResolverTests.cs | 262 ++++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs new file mode 100644 index 0000000..fd89a26 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs @@ -0,0 +1,158 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Server.Alarms; + +/// +/// Default . Merges Galaxy Repository +/// alarm-attribute discovery with the configured include/exclude overrides +/// and composes the per-attribute subtag item addresses from the configured +/// subtag names. +/// +// NOTE: The exact subtag names and the canonical AlarmFullReference shape +// ("Galaxy!{area}.{reference}") are validated against a live Galaxy in the +// Task 17 live smoke test. The config Subtags block exists precisely so these +// names are not hard-coded here. +public sealed class AlarmWatchListResolver : IAlarmWatchListResolver +{ + private const string ProviderLiteral = "Galaxy"; + private const string DefaultActiveSubtag = "active"; + private const string DefaultAckedSubtag = "acked"; + + private readonly IGalaxyRepository _repository; + private readonly ILogger _logger; + + /// Initializes the watch-list resolver. + /// Galaxy Repository used for alarm-attribute discovery. + /// Diagnostic logger. + public AlarmWatchListResolver( + IGalaxyRepository repository, + ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task> ResolveAsync( + AlarmsOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + + AlarmDiscoveryOptions discovery = options.Fallback.Discovery; + + // 1. Build the ordered, de-duplicated attribute reference set. + // Each entry carries the reference plus the source-object reference. + List<(string Reference, string SourceObject)> ordered = []; + HashSet seen = new(StringComparer.OrdinalIgnoreCase); + + if (discovery.UseGalaxyRepository) + { + List rows; + try + { + rows = await _repository.GetAlarmAttributesAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + // Discovery being unavailable must not crash the resolver: log and + // continue with an empty discovery set. The caller decides what to + // do with the (possibly config-only) result. + _logger.LogWarning( + ex, + "Galaxy Repository alarm-attribute discovery failed; continuing with configuration-only watch-list."); + rows = []; + } + + foreach (GalaxyAlarmAttributeRow row in rows) + { + if (string.IsNullOrEmpty(row.FullTagReference) || !seen.Add(row.FullTagReference)) + { + continue; + } + + ordered.Add((row.FullTagReference, row.SourceObjectReference)); + } + } + + foreach (string include in discovery.IncludeAttributes) + { + if (string.IsNullOrEmpty(include) || !seen.Add(include)) + { + continue; + } + + ordered.Add((include, DeriveSourceObject(include))); + } + + // Remove excluded references (case-insensitive). Excludes are rare, so a + // per-entry lookup against a small set is fine. + HashSet excluded = new(discovery.ExcludeAttributes, StringComparer.OrdinalIgnoreCase); + if (excluded.Count > 0) + { + ordered.RemoveAll(e => excluded.Contains(e.Reference)); + } + + // 2. Resolve subtag names with safe fallbacks. + string active = string.IsNullOrEmpty(options.Fallback.Subtags.Active) + ? DefaultActiveSubtag + : options.Fallback.Subtags.Active; + string acked = string.IsNullOrEmpty(options.Fallback.Subtags.Acked) + ? DefaultAckedSubtag + : options.Fallback.Subtags.Acked; + string priority = options.Fallback.Subtags.Priority; + string ackComment = options.Fallback.Subtags.AckComment; + + // 3. Resolve the area: discovery area, else the default area (may be empty). + string area = string.IsNullOrEmpty(discovery.Area) ? options.DefaultArea : discovery.Area; + + // 4. Compose one target per reference. + List targets = new(ordered.Count); + foreach ((string reference, string sourceObject) in ordered) + { + targets.Add(new AlarmSubtagTarget + { + AlarmFullReference = ComposeFullReference(area, reference), + SourceObjectReference = sourceObject, + ActiveSubtag = $"{reference}.{active}", + AckedSubtag = $"{reference}.{acked}", + PrioritySubtag = string.IsNullOrEmpty(priority) ? string.Empty : $"{reference}.{priority}", + AckCommentSubtag = string.IsNullOrEmpty(ackComment) ? string.Empty : $"{reference}.{ackComment}", + }); + } + + // 5. Report the resolved count; warn if subtag mode would cover nothing. + if (targets.Count == 0 && discovery.UseGalaxyRepository) + { + _logger.LogWarning( + "Alarm subtag watch-list resolved to zero targets; subtag-polling fallback will cover no alarms."); + } + else + { + _logger.LogDebug("Resolved alarm subtag watch-list with {TargetCount} target(s).", targets.Count); + } + + return targets; + } + + /// + /// Derives the source-object reference for a configuration entry: the + /// substring before the first '.', or the whole string when there is no dot. + /// + private static string DeriveSourceObject(string reference) + { + int dot = reference.IndexOf('.', StringComparison.Ordinal); + return dot < 0 ? reference : reference[..dot]; + } + + /// + /// Composes the canonical alarm full reference: Galaxy!{area}.{reference} + /// when an area is set, otherwise Galaxy!{reference}. + /// + private static string ComposeFullReference(string area, string reference) => + string.IsNullOrEmpty(area) + ? $"{ProviderLiteral}!{reference}" + : $"{ProviderLiteral}!{area}.{reference}"; +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs new file mode 100644 index 0000000..7581d5c --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs @@ -0,0 +1,28 @@ +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Configuration; + +namespace ZB.MOM.WW.MxGateway.Server.Alarms; + +/// +/// Resolves the subtag watch-list the gateway sends to the worker when the +/// central alarm monitor operates in subtag-polling fallback mode. Merges +/// Galaxy Repository alarm-attribute discovery with the configured +/// include/exclude overrides and composes the per-attribute subtag item +/// addresses from the configured subtag names. +/// +public interface IAlarmWatchListResolver +{ + /// + /// Builds the subtag watch-list for the supplied alarm configuration. + /// + /// Alarm configuration carrying discovery and subtag-name settings. + /// Token to cancel the asynchronous operation. + /// + /// The resolved watch-list, possibly empty. + /// Discovery being unavailable never throws; the caller decides what to do + /// with an empty list. + /// + Task> ResolveAsync( + AlarmsOptions options, + CancellationToken cancellationToken = default); +} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs new file mode 100644 index 0000000..c6b5262 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs @@ -0,0 +1,262 @@ +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Server.Alarms; +using ZB.MOM.WW.MxGateway.Server.Configuration; +using ZB.MOM.WW.MxGateway.Server.Galaxy; + +namespace ZB.MOM.WW.MxGateway.Tests.Alarms; + +/// +/// Unit tests for : discovery/config merge, +/// subtag-address composition, canonical reference shaping, and the +/// unavailable-discovery code path. +/// +public sealed class AlarmWatchListResolverTests +{ + private static AlarmWatchListResolver CreateResolver(IGalaxyRepository repository) => + new(repository, NullLogger.Instance); + + private static AlarmsOptions Options( + bool useGalaxyRepository = true, + string area = "", + string defaultArea = "", + string[]? include = null, + string[]? exclude = null, + AlarmSubtagNameOptions? subtags = null) => + new() + { + DefaultArea = defaultArea, + Fallback = new AlarmFallbackOptions + { + Discovery = new AlarmDiscoveryOptions + { + UseGalaxyRepository = useGalaxyRepository, + Area = area, + IncludeAttributes = include ?? [], + ExcludeAttributes = exclude ?? [], + }, + Subtags = subtags ?? new AlarmSubtagNameOptions(), + }, + }; + + [Fact] + public async Task ResolveAsync_UnionsGalaxyRowsAndIncludes_RemovesExcludes_AndDeduplicates() + { + StubGalaxyRepository repo = new( + [ + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, + new GalaxyAlarmAttributeRow { FullTagReference = "Tank02.Level.HiHi", SourceObjectReference = "Tank02" }, + // Duplicate of an include below (case-insensitive) — should appear once. + new GalaxyAlarmAttributeRow { FullTagReference = "Pump01.Fault", SourceObjectReference = "Pump01" }, + ]); + + AlarmWatchListResolver resolver = CreateResolver(repo); + + IReadOnlyList result = await resolver.ResolveAsync(Options( + include: ["pump01.fault", "Valve03.Position.Lo"], + exclude: ["Tank02.Level.HiHi"])); + + Assert.Equal( + new[] { "Tank01.Level.HiHi", "Pump01.Fault", "Valve03.Position.Lo" }, + result.Select(t => t.ActiveSubtag.Replace(".active", string.Empty, StringComparison.Ordinal))); + // De-dup preserved first (GR) occurrence; exclude removed Tank02. + Assert.Equal(3, result.Count); + } + + [Fact] + public async Task ResolveAsync_ComposesSubtagAddressesFromConfigNames() + { + StubGalaxyRepository repo = new( + [ + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, + ]); + + AlarmWatchListResolver resolver = CreateResolver(repo); + + IReadOnlyList result = await resolver.ResolveAsync(Options( + subtags: new AlarmSubtagNameOptions + { + Active = "InAlarm", + Acked = "Ack", + Priority = "Pri", + AckComment = "AckCmt", + })); + + AlarmSubtagTarget target = Assert.Single(result); + Assert.Equal("Tank01.Level.HiHi.InAlarm", target.ActiveSubtag); + Assert.Equal("Tank01.Level.HiHi.Ack", target.AckedSubtag); + Assert.Equal("Tank01.Level.HiHi.Pri", target.PrioritySubtag); + Assert.Equal("Tank01.Level.HiHi.AckCmt", target.AckCommentSubtag); + } + + [Fact] + public async Task ResolveAsync_EmptyPriorityAndAckComment_LeaveThoseFieldsEmpty() + { + StubGalaxyRepository repo = new( + [ + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, + ]); + + AlarmWatchListResolver resolver = CreateResolver(repo); + + // Default Priority is "priority"; force it empty alongside empty AckComment. + IReadOnlyList result = await resolver.ResolveAsync(Options( + subtags: new AlarmSubtagNameOptions { Priority = string.Empty, AckComment = string.Empty })); + + AlarmSubtagTarget target = Assert.Single(result); + Assert.Equal("Tank01.Level.HiHi.active", target.ActiveSubtag); + Assert.Equal("Tank01.Level.HiHi.acked", target.AckedSubtag); + Assert.Equal(string.Empty, target.PrioritySubtag); + Assert.Equal(string.Empty, target.AckCommentSubtag); + } + + [Fact] + public async Task ResolveAsync_ComposesCanonicalFullReference_WithArea() + { + StubGalaxyRepository repo = new( + [ + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, + ]); + + AlarmWatchListResolver resolver = CreateResolver(repo); + + IReadOnlyList result = await resolver.ResolveAsync(Options(area: "Site_A")); + + AlarmSubtagTarget target = Assert.Single(result); + Assert.Equal("Galaxy!Site_A.Tank01.Level.HiHi", target.AlarmFullReference); + } + + [Fact] + public async Task ResolveAsync_ComposesCanonicalFullReference_WithoutArea() + { + StubGalaxyRepository repo = new( + [ + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, + ]); + + AlarmWatchListResolver resolver = CreateResolver(repo); + + // No discovery area and no default area. + IReadOnlyList result = await resolver.ResolveAsync(Options()); + + AlarmSubtagTarget target = Assert.Single(result); + Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference); + } + + [Fact] + public async Task ResolveAsync_FallsBackToDefaultArea_WhenDiscoveryAreaEmpty() + { + StubGalaxyRepository repo = new( + [ + new GalaxyAlarmAttributeRow { FullTagReference = "Tank01.Level.HiHi", SourceObjectReference = "Tank01" }, + ]); + + AlarmWatchListResolver resolver = CreateResolver(repo); + + IReadOnlyList result = await resolver.ResolveAsync(Options(area: "", defaultArea: "Plant")); + + AlarmSubtagTarget target = Assert.Single(result); + Assert.Equal("Galaxy!Plant.Tank01.Level.HiHi", target.AlarmFullReference); + } + + [Fact] + public async Task ResolveAsync_UseGalaxyRepositoryFalse_DoesNotCallRepository_UsesIncludesOnly() + { + StubGalaxyRepository repo = new( + [ + new GalaxyAlarmAttributeRow { FullTagReference = "ShouldNotAppear.X", SourceObjectReference = "ShouldNotAppear" }, + ]); + + AlarmWatchListResolver resolver = CreateResolver(repo); + + IReadOnlyList result = await resolver.ResolveAsync(Options( + useGalaxyRepository: false, + include: ["Tank01.Level.HiHi"])); + + Assert.Equal(0, repo.GetAlarmAttributesCount); + AlarmSubtagTarget target = Assert.Single(result); + Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference); + } + + [Fact] + public async Task ResolveAsync_RepositoryThrows_LogsAndReturnsConfigOnlySet() + { + ThrowingGalaxyRepository repo = new(new InvalidOperationException("SQL unavailable")); + + AlarmWatchListResolver resolver = CreateResolver(repo); + + IReadOnlyList result = await resolver.ResolveAsync(Options( + include: ["Tank01.Level.HiHi"])); + + // Did not throw; discovery set empty, include retained. + AlarmSubtagTarget target = Assert.Single(result); + Assert.Equal("Galaxy!Tank01.Level.HiHi", target.AlarmFullReference); + } + + [Fact] + public async Task ResolveAsync_DerivesSourceObjectForConfigEntry() + { + StubGalaxyRepository repo = new([]); + + AlarmWatchListResolver resolver = CreateResolver(repo); + + IReadOnlyList result = await resolver.ResolveAsync(Options( + include: ["Tank01.Level.HiHi", "StandaloneTag"])); + + Assert.Equal("Tank01", result[0].SourceObjectReference); + // No dot — whole string is the source object. + Assert.Equal("StandaloneTag", result[1].SourceObjectReference); + } + + /// In-memory returning a fixed alarm rowset. + private sealed class StubGalaxyRepository(List rows) : IGalaxyRepository + { + /// Gets the number of times was called. + public int GetAlarmAttributesCount { get; private set; } + + /// + public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true); + + /// + public Task GetLastDeployTimeAsync(CancellationToken ct = default) => + Task.FromResult(null); + + /// + public Task> GetHierarchyAsync(CancellationToken ct = default) => + Task.FromResult(new List()); + + /// + public Task> GetAttributesAsync(CancellationToken ct = default) => + Task.FromResult(new List()); + + /// + public Task> GetAlarmAttributesAsync(CancellationToken ct = default) + { + GetAlarmAttributesCount++; + return Task.FromResult(rows); + } + } + + /// whose alarm-attribute query throws. + private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository + { + /// + public Task TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true); + + /// + public Task GetLastDeployTimeAsync(CancellationToken ct = default) => + Task.FromResult(null); + + /// + public Task> GetHierarchyAsync(CancellationToken ct = default) => + Task.FromResult(new List()); + + /// + public Task> GetAttributesAsync(CancellationToken ct = default) => + Task.FromResult(new List()); + + /// + public Task> GetAlarmAttributesAsync(CancellationToken ct = default) => + Task.FromException>(toThrow); + } +}