server(alarms): watch-list resolver merging GR discovery + config override

This commit is contained in:
Joseph Doherty
2026-06-13 10:09:10 -04:00
parent 3f5e5fc0b3
commit f7ccfd678e
3 changed files with 448 additions and 0 deletions
@@ -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;
/// <summary>
/// Default <see cref="IAlarmWatchListResolver"/>. 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.
/// </summary>
// 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<AlarmWatchListResolver> _logger;
/// <summary>Initializes the watch-list resolver.</summary>
/// <param name="repository">Galaxy Repository used for alarm-attribute discovery.</param>
/// <param name="logger">Diagnostic logger.</param>
public AlarmWatchListResolver(
IGalaxyRepository repository,
ILogger<AlarmWatchListResolver> logger)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<IReadOnlyList<AlarmSubtagTarget>> 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<string> seen = new(StringComparer.OrdinalIgnoreCase);
if (discovery.UseGalaxyRepository)
{
List<GalaxyAlarmAttributeRow> 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<string> 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<AlarmSubtagTarget> 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;
}
/// <summary>
/// Derives the source-object reference for a configuration entry: the
/// substring before the first '.', or the whole string when there is no dot.
/// </summary>
private static string DeriveSourceObject(string reference)
{
int dot = reference.IndexOf('.', StringComparison.Ordinal);
return dot < 0 ? reference : reference[..dot];
}
/// <summary>
/// Composes the canonical alarm full reference: <c>Galaxy!{area}.{reference}</c>
/// when an area is set, otherwise <c>Galaxy!{reference}</c>.
/// </summary>
private static string ComposeFullReference(string area, string reference) =>
string.IsNullOrEmpty(area)
? $"{ProviderLiteral}!{reference}"
: $"{ProviderLiteral}!{area}.{reference}";
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
public interface IAlarmWatchListResolver
{
/// <summary>
/// Builds the subtag watch-list for the supplied alarm configuration.
/// </summary>
/// <param name="options">Alarm configuration carrying discovery and subtag-name settings.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>
/// The resolved <see cref="AlarmSubtagTarget"/> watch-list, possibly empty.
/// Discovery being unavailable never throws; the caller decides what to do
/// with an empty list.
/// </returns>
Task<IReadOnlyList<AlarmSubtagTarget>> ResolveAsync(
AlarmsOptions options,
CancellationToken cancellationToken = default);
}
@@ -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;
/// <summary>
/// Unit tests for <see cref="AlarmWatchListResolver"/>: discovery/config merge,
/// subtag-address composition, canonical reference shaping, and the
/// unavailable-discovery code path.
/// </summary>
public sealed class AlarmWatchListResolverTests
{
private static AlarmWatchListResolver CreateResolver(IGalaxyRepository repository) =>
new(repository, NullLogger<AlarmWatchListResolver>.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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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<AlarmSubtagTarget> 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);
}
/// <summary>In-memory <see cref="IGalaxyRepository"/> returning a fixed alarm rowset.</summary>
private sealed class StubGalaxyRepository(List<GalaxyAlarmAttributeRow> rows) : IGalaxyRepository
{
/// <summary>Gets the number of times <see cref="GetAlarmAttributesAsync"/> was called.</summary>
public int GetAlarmAttributesCount { get; private set; }
/// <inheritdoc />
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
/// <inheritdoc />
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(null);
/// <inheritdoc />
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyHierarchyRow>());
/// <inheritdoc />
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyAttributeRow>());
/// <inheritdoc />
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
{
GetAlarmAttributesCount++;
return Task.FromResult(rows);
}
}
/// <summary><see cref="IGalaxyRepository"/> whose alarm-attribute query throws.</summary>
private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository
{
/// <inheritdoc />
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(true);
/// <inheritdoc />
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default) =>
Task.FromResult<DateTime?>(null);
/// <inheritdoc />
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyHierarchyRow>());
/// <inheritdoc />
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default) =>
Task.FromResult(new List<GalaxyAttributeRow>());
/// <inheritdoc />
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default) =>
Task.FromException<List<GalaxyAlarmAttributeRow>>(toThrow);
}
}