Compare commits
5 Commits
60d35a914f
...
c75920c620
| Author | SHA1 | Date | |
|---|---|---|---|
| c75920c620 | |||
| a46ce90e6f | |||
| f113ca53a1 | |||
| f3616cc7fa | |||
| 57d5a8725f |
@@ -38,9 +38,11 @@
|
||||
**Parallelizable with:** none (Task 2 imports these types)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto` (alarm command block, ~lines 318-346)
|
||||
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (real `SubscribeAlarmsCommand` at ~line 324; `MxCommand` references it at 123-125)
|
||||
|
||||
**Step 1: Add the enum and messages.** In `mxaccess_worker.proto`, replace the `SubscribeAlarmsCommand` message and add the new types after it:
|
||||
> **CORRECTION (execution):** The alarm command messages and `MxCommand` live in **`mxaccess_gateway.proto`**, not the worker proto. `mxaccess_worker.proto` *imports* the gateway proto (`WorkerCommand.command` is `mxaccess_gateway.v1.MxCommand`), so the gateway proto is the base and the worker proto needs **no** change. `AlarmProviderMode` and the new types are added to the gateway proto and are visible to worker code as `mxaccess_gateway.v1` types. Tasks 1 and 2 are executed as a single combined edit on this one file.
|
||||
|
||||
**Step 1: Add the enum and messages.** In `mxaccess_gateway.proto`, extend the existing `SubscribeAlarmsCommand` message (line 324) and add the new types after it:
|
||||
|
||||
```protobuf
|
||||
// Provider selection / current provider for the alarm feed. Defined here in
|
||||
@@ -53,7 +55,7 @@ enum AlarmProviderMode {
|
||||
}
|
||||
|
||||
message SubscribeAlarmsCommand {
|
||||
string subscription_expression = 1;
|
||||
string subscription_expression = 1; // existing field — keep
|
||||
// UNSPECIFIED = auto-failover/failback. ALARMMGR/SUBTAG force one provider.
|
||||
AlarmProviderMode forced_mode = 2;
|
||||
// Subtag watch-list resolved by the gateway (GR SQL + config). Empty in pure
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Controls how the central alarm monitor selects between the MXAccess
|
||||
/// alarm-manager subscription and the subtag-polling fallback, and
|
||||
/// governs the failure-detection thresholds used when switching.
|
||||
/// </summary>
|
||||
public sealed class AlarmFallbackOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Selects the operating mode for the alarm-manager ↔ subtag fallback
|
||||
/// mechanism. Accepted values (case-insensitive):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Auto</c> — use the alarm manager; switch to subtag polling
|
||||
/// automatically when <see cref="ConsecutiveFailureThreshold"/> failures
|
||||
/// are detected, and probe for failback.</item>
|
||||
/// <item><c>ForceAlarmManager</c> — always use the alarm manager;
|
||||
/// never fall back.</item>
|
||||
/// <item><c>ForceSubtag</c> — always use subtag polling;
|
||||
/// never try the alarm manager.</item>
|
||||
/// </list>
|
||||
/// Default is <c>Auto</c>.
|
||||
/// </summary>
|
||||
public string Mode { get; init; } = "Auto";
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive alarm-manager failures before the monitor
|
||||
/// switches to subtag-polling fallback. Must be at least 1. Default 3.
|
||||
/// </summary>
|
||||
public int ConsecutiveFailureThreshold { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// How often (in seconds) the monitor sends a probe to the alarm manager
|
||||
/// while operating in subtag-polling fallback mode, to detect recovery.
|
||||
/// Must be at least 1. Default 30.
|
||||
/// </summary>
|
||||
public int FailbackProbeIntervalSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive successful probes required before the monitor
|
||||
/// considers the alarm manager recovered and switches back. Must be at
|
||||
/// least 1. Default 3.
|
||||
/// </summary>
|
||||
public int FailbackStableProbes { get; init; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Controls how the monitor discovers the set of objects to poll when
|
||||
/// operating in subtag-polling fallback mode.
|
||||
/// </summary>
|
||||
public AlarmDiscoveryOptions Discovery { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Configures the subtag names the monitor reads when polling alarm state
|
||||
/// in subtag-fallback mode.
|
||||
/// </summary>
|
||||
public AlarmSubtagNameOptions Subtags { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Governs how the alarm monitor discovers objects to include in subtag-polling
|
||||
/// fallback mode. Either the Galaxy Repository query (when
|
||||
/// <see cref="UseGalaxyRepository"/> is <c>true</c>) or an explicit
|
||||
/// <see cref="IncludeAttributes"/> list must be supplied when
|
||||
/// <c>MxGateway:Alarms:Fallback:Mode</c> is <c>ForceSubtag</c>.
|
||||
/// </summary>
|
||||
public sealed class AlarmDiscoveryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// When <c>true</c> the monitor queries the Galaxy Repository SQL database
|
||||
/// to enumerate alarm objects for the configured area. Default <c>true</c>.
|
||||
/// </summary>
|
||||
public bool UseGalaxyRepository { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Galaxy area to scope the Repository query to. When empty the monitor
|
||||
/// falls back to <see cref="AlarmsOptions.DefaultArea"/>. Ignored when
|
||||
/// <see cref="UseGalaxyRepository"/> is <c>false</c>.
|
||||
/// </summary>
|
||||
public string Area { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Explicit list of MXAccess attribute paths to include in subtag polling,
|
||||
/// supplementing (or replacing, when <see cref="UseGalaxyRepository"/> is
|
||||
/// <c>false</c>) the Repository-derived list. Default empty.
|
||||
/// </summary>
|
||||
public string[] IncludeAttributes { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Attribute paths to exclude from the Repository-derived poll list.
|
||||
/// Ignored when <see cref="UseGalaxyRepository"/> is <c>false</c>.
|
||||
/// Default empty.
|
||||
/// </summary>
|
||||
public string[] ExcludeAttributes { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the subtag names read by the alarm monitor when it is operating
|
||||
/// in subtag-polling fallback mode. Names are matched against MXAccess item
|
||||
/// handles; validation against the live MXAccess attribute list occurs at
|
||||
/// runtime, not at startup.
|
||||
/// </summary>
|
||||
public sealed class AlarmSubtagNameOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Subtag name for the active-alarm flag. Default <c>active</c>.
|
||||
/// </summary>
|
||||
public string Active { get; init; } = "active";
|
||||
|
||||
/// <summary>
|
||||
/// Subtag name for the acknowledged flag. Default <c>acked</c>.
|
||||
/// </summary>
|
||||
public string Acked { get; init; } = "acked";
|
||||
|
||||
/// <summary>
|
||||
/// Optional subtag name for the acknowledgement comment field.
|
||||
/// When empty the feature is disabled. Verified against MXAccess at
|
||||
/// runtime before use. Default empty.
|
||||
/// </summary>
|
||||
public string AckComment { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Subtag name for the alarm priority. Default <c>priority</c>.
|
||||
/// </summary>
|
||||
public string Priority { get; init; } = "priority";
|
||||
}
|
||||
@@ -45,4 +45,12 @@ public sealed class AlarmsOptions
|
||||
/// the monitor floors it at 5 seconds.
|
||||
/// </summary>
|
||||
public int ReconcileIntervalSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
|
||||
/// operating mode, failure-detection thresholds, discovery, and subtag
|
||||
/// names. Defaults (Mode = "Auto") preserve behaviour when the section is
|
||||
/// omitted from configuration.
|
||||
/// </summary>
|
||||
public AlarmFallbackOptions Fallback { get; init; } = new();
|
||||
}
|
||||
|
||||
@@ -231,6 +231,8 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
builder);
|
||||
}
|
||||
|
||||
private static readonly string[] ValidAlarmFallbackModes = ["Auto", "ForceAlarmManager", "ForceSubtag"];
|
||||
|
||||
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
@@ -255,6 +257,46 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
||||
builder.Add(
|
||||
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
||||
}
|
||||
|
||||
ValidateAlarmFallback(options.Fallback, builder);
|
||||
}
|
||||
|
||||
private static void ValidateAlarmFallback(AlarmFallbackOptions fallback, ValidationBuilder builder)
|
||||
{
|
||||
// Validate Mode is one of the recognised values (case-insensitive).
|
||||
bool modeValid = Array.Exists(
|
||||
ValidAlarmFallbackModes,
|
||||
m => string.Equals(m, fallback.Mode, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!modeValid)
|
||||
{
|
||||
builder.Add(
|
||||
$"MxGateway:Alarms:Fallback:Mode must be one of: {string.Join(", ", ValidAlarmFallbackModes)} (was '{fallback.Mode}').");
|
||||
}
|
||||
|
||||
// ForceSubtag requires either Galaxy Repository discovery or an explicit IncludeAttributes list.
|
||||
if (modeValid
|
||||
&& string.Equals(fallback.Mode, "ForceSubtag", StringComparison.OrdinalIgnoreCase)
|
||||
&& !fallback.Discovery.UseGalaxyRepository
|
||||
&& fallback.Discovery.IncludeAttributes.Length == 0)
|
||||
{
|
||||
builder.Add(
|
||||
"MxGateway:Alarms:Fallback ForceSubtag requires Galaxy Repository discovery or a non-empty Discovery:IncludeAttributes list.");
|
||||
}
|
||||
|
||||
// Floor validation: numeric thresholds must be at least 1.
|
||||
AddIfNotPositive(
|
||||
fallback.ConsecutiveFailureThreshold,
|
||||
"MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold must be greater than zero.",
|
||||
builder);
|
||||
AddIfNotPositive(
|
||||
fallback.FailbackProbeIntervalSeconds,
|
||||
"MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds must be greater than zero.",
|
||||
builder);
|
||||
AddIfNotPositive(
|
||||
fallback.FailbackStableProbes,
|
||||
"MxGateway:Alarms:Fallback:FailbackStableProbes must be greater than zero.",
|
||||
builder);
|
||||
}
|
||||
|
||||
private const int MinimumCertValidityYears = 1;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// One alarm-bearing attribute discovered by
|
||||
/// <see cref="GalaxyRepository.GetAlarmAttributesAsync"/>: an attribute whose owning
|
||||
/// object configures an <c>AlarmExtension</c> primitive (the same
|
||||
/// <c>is_alarm</c> detection used by <see cref="GalaxyRepository.GetAttributesAsync"/>).
|
||||
/// Used to build the subtag-fallback watch-list.
|
||||
/// </summary>
|
||||
public sealed class GalaxyAlarmAttributeRow
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the alarm-bearing attribute reference (e.g. <c>Tank01.Level.HiHi</c>),
|
||||
/// matching the <c>full_tag_reference</c> projection of
|
||||
/// <see cref="GalaxyRepository.GetAttributesAsync"/>.
|
||||
/// </summary>
|
||||
public string FullTagReference { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the owning object reference (e.g. <c>Tank01</c>). This is the Galaxy
|
||||
/// <c>tag_name</c> — the segment that precedes the first attribute dot in
|
||||
/// <see cref="FullTagReference"/>.
|
||||
/// </summary>
|
||||
public string SourceObjectReference { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the writable ack-comment attribute address.
|
||||
/// <para>
|
||||
/// The Galaxy Repository schema does not expose an ack-comment subtag address
|
||||
/// directly, so this is always <see cref="string.Empty"/> here. The watch-list
|
||||
/// resolver (a later task) composes the concrete address from configuration plus
|
||||
/// <see cref="SourceObjectReference"/> / <see cref="FullTagReference"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string AckCommentSubtag { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -114,6 +114,56 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves only the alarm-bearing attributes for the subtag-fallback watch-list.
|
||||
/// Alarm detection is identical to <see cref="GetAttributesAsync"/>: a row is
|
||||
/// alarm-bearing when its owning object configures an <c>AlarmExtension</c>
|
||||
/// primitive (the same <c>is_alarm</c> projection, here applied as a SQL filter).
|
||||
/// </summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
public async Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
List<GalaxyAlarmAttributeRow> rows = new();
|
||||
|
||||
using SqlConnection conn = new(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
using SqlCommand cmd = new(AlarmAttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
rows.Add(MapAlarmRow(
|
||||
fullTagReference: reader.GetString(0),
|
||||
sourceObjectReference: reader.GetString(1)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a raw alarm-attribute reader row to a <see cref="GalaxyAlarmAttributeRow"/>.
|
||||
/// <para>
|
||||
/// <paramref name="sourceObjectReference"/> is the Galaxy <c>tag_name</c> (the
|
||||
/// owning object), and <paramref name="fullTagReference"/> is
|
||||
/// <c>tag_name + '.' + attribute_name</c> — the same composition the
|
||||
/// <c>full_tag_reference</c> projection of <see cref="AttributesSql"/> produces.
|
||||
/// <see cref="GalaxyAlarmAttributeRow.AckCommentSubtag"/> is left empty here; the
|
||||
/// schema does not expose an ack-comment address and the watch-list resolver
|
||||
/// composes it later.
|
||||
/// </para>
|
||||
/// Exposed internally so the derivation can be unit-tested without a database.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The alarm-bearing attribute reference.</param>
|
||||
/// <param name="sourceObjectReference">The owning object reference (tag name).</param>
|
||||
internal static GalaxyAlarmAttributeRow MapAlarmRow(
|
||||
string fullTagReference,
|
||||
string sourceObjectReference) => new()
|
||||
{
|
||||
FullTagReference = fullTagReference,
|
||||
SourceObjectReference = sourceObjectReference,
|
||||
AckCommentSubtag = string.Empty,
|
||||
};
|
||||
|
||||
private const string HierarchySql = @"
|
||||
;WITH template_chain AS (
|
||||
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
|
||||
@@ -248,5 +298,71 @@ SELECT
|
||||
FROM ranked r
|
||||
LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type
|
||||
WHERE r.rn = 1
|
||||
ORDER BY r.tag_name, r.attribute_name";
|
||||
|
||||
// Alarm-only discovery for the subtag-fallback watch-list. This deliberately reuses the
|
||||
// exact candidate/ranked CTE structure and the same `AlarmExtension`-based is_alarm
|
||||
// detection as AttributesSql so the two queries cannot drift: a row qualifies only when
|
||||
// its user attribute (src_pri 0) anchors an `AlarmExtension` primitive on the owning
|
||||
// object. It projects just what the watch-list needs — full_tag_reference (tag_name +
|
||||
// '.' + attribute_name, matching AttributesSql) and the owning object's tag_name as
|
||||
// source_object_reference. The array `[]` suffix is intentionally omitted: an
|
||||
// alarm-bearing attribute is a scalar anchor, not an array body.
|
||||
private const string AlarmAttributesSql = @"
|
||||
;WITH deployed_package_chain AS (
|
||||
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
|
||||
FROM gobject g
|
||||
INNER JOIN package p ON p.package_id = g.deployed_package_id
|
||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
|
||||
UNION ALL
|
||||
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
|
||||
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
|
||||
),
|
||||
candidate AS (
|
||||
SELECT
|
||||
dpc.gobject_id, g.tag_name, da.attribute_name, dpc.depth, 0 AS src_pri
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id
|
||||
INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
|
||||
INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
|
||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||
AND da.attribute_name NOT LIKE '[_]%'
|
||||
AND da.attribute_name NOT LIKE '%.Description'
|
||||
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
|
||||
UNION ALL
|
||||
SELECT
|
||||
dpc.gobject_id, g.tag_name,
|
||||
CASE WHEN pi.primitive_name IS NULL OR pi.primitive_name = ''
|
||||
THEN ad.attribute_name
|
||||
ELSE pi.primitive_name + '.' + ad.attribute_name END AS attribute_name,
|
||||
dpc.depth, 1 AS src_pri
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc.package_id
|
||||
INNER JOIN attribute_definition ad ON ad.primitive_definition_id = pi.primitive_definition_id
|
||||
INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
|
||||
INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
|
||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||
AND ad.attribute_name NOT LIKE '[_]%'
|
||||
AND ad.attribute_name NOT LIKE '%.Description'
|
||||
),
|
||||
ranked AS (
|
||||
SELECT c.*, ROW_NUMBER() OVER (
|
||||
PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.src_pri, c.depth) AS rn
|
||||
FROM candidate c
|
||||
)
|
||||
SELECT
|
||||
r.tag_name + '.' + r.attribute_name AS full_tag_reference,
|
||||
r.tag_name AS source_object_reference
|
||||
FROM ranked r
|
||||
WHERE r.rn = 1
|
||||
AND r.src_pri = 0
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM deployed_package_chain dpc2
|
||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name
|
||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
|
||||
WHERE dpc2.gobject_id = r.gobject_id
|
||||
)
|
||||
ORDER BY r.tag_name, r.attribute_name";
|
||||
}
|
||||
|
||||
@@ -27,4 +27,12 @@ public interface IGalaxyRepository
|
||||
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves only the alarm-bearing attributes (those whose owning object
|
||||
/// configures an <c>AlarmExtension</c> primitive) for building the
|
||||
/// subtag-fallback watch-list.
|
||||
/// </summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Globalization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||
|
||||
@@ -22,6 +23,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
private readonly Counter<long> _heartbeatFailuresCounter;
|
||||
private readonly Counter<long> _streamDisconnectsCounter;
|
||||
private readonly Counter<long> _retryAttemptsCounter;
|
||||
private readonly Counter<long> _alarmProviderSwitchesCounter;
|
||||
private readonly Histogram<double> _workerStartupLatencyHistogram;
|
||||
private readonly Histogram<double> _commandLatencyHistogram;
|
||||
private readonly Histogram<double> _eventStreamSendLatencyHistogram;
|
||||
@@ -34,6 +36,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
private int _workersRunning;
|
||||
private int _workerEventQueueDepth;
|
||||
private int _grpcEventStreamQueueDepth;
|
||||
private int _alarmProviderMode;
|
||||
private long _sessionsOpened;
|
||||
private long _sessionsClosed;
|
||||
private long _commandsStarted;
|
||||
@@ -68,6 +71,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
_heartbeatFailuresCounter = _meter.CreateCounter<long>("mxgateway.heartbeats.failed");
|
||||
_streamDisconnectsCounter = _meter.CreateCounter<long>("mxgateway.grpc.streams.disconnected");
|
||||
_retryAttemptsCounter = _meter.CreateCounter<long>("mxgateway.retries.attempted");
|
||||
_alarmProviderSwitchesCounter = _meter.CreateCounter<long>("mxgateway.alarms.provider_switches");
|
||||
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
|
||||
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
|
||||
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
|
||||
@@ -76,6 +80,7 @@ public sealed class GatewayMetrics : IDisposable
|
||||
_meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning);
|
||||
_meter.CreateObservableGauge("mxgateway.events.worker_queue.depth", GetWorkerEventQueueDepth);
|
||||
_meter.CreateObservableGauge("mxgateway.events.grpc_stream_queue.depth", GetGrpcEventStreamQueueDepth);
|
||||
_meter.CreateObservableGauge("mxgateway.alarms.provider_mode", GetAlarmProviderMode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -377,6 +382,26 @@ public sealed class GatewayMetrics : IDisposable
|
||||
_retryAttemptsCounter.Add(1, new KeyValuePair<string, object?>("area", area));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that the alarm provider switched modes and updates the current provider mode gauge.
|
||||
/// </summary>
|
||||
/// <param name="fromMode">Provider mode before the switch (1=alarmmgr, 2=subtag, 0=unknown).</param>
|
||||
/// <param name="toMode">Provider mode after the switch (1=alarmmgr, 2=subtag, 0=unknown).</param>
|
||||
/// <param name="reason">Human-readable reason for the switch.</param>
|
||||
public void AlarmProviderSwitched(int fromMode, int toMode, string reason)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_alarmProviderMode = toMode;
|
||||
}
|
||||
|
||||
_alarmProviderSwitchesCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("from", fromMode.ToString(CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, object?>("to", toMode.ToString(CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, object?>("reason", reason ?? string.Empty));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of all current metric values.
|
||||
/// </summary>
|
||||
@@ -455,6 +480,14 @@ public sealed class GatewayMetrics : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private int GetAlarmProviderMode()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _alarmProviderMode;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Increment(Dictionary<string, long> values, string key)
|
||||
{
|
||||
values.TryGetValue(key, out long currentValue);
|
||||
|
||||
@@ -118,4 +118,181 @@ public sealed class GatewayOptionsValidatorTests
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// AlarmFallbackOptions validation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static AlarmsOptions EnabledAlarmsWithFallback(AlarmFallbackOptions fallback) => new()
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultArea = "Galaxy",
|
||||
Fallback = fallback,
|
||||
};
|
||||
|
||||
private static GatewayOptions CloneWithAlarms(GatewayOptions source, AlarmsOptions alarms)
|
||||
=> new()
|
||||
{
|
||||
Authentication = source.Authentication,
|
||||
Ldap = source.Ldap,
|
||||
Worker = source.Worker,
|
||||
Sessions = source.Sessions,
|
||||
Events = source.Events,
|
||||
Dashboard = source.Dashboard,
|
||||
Protocol = source.Protocol,
|
||||
Alarms = alarms,
|
||||
Tls = source.Tls,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated()
|
||||
{
|
||||
// Even an invalid Mode is acceptable when Enabled = false.
|
||||
GatewayOptions options = CloneWithAlarms(
|
||||
ValidOptions(),
|
||||
new AlarmsOptions
|
||||
{
|
||||
Enabled = false,
|
||||
Fallback = new AlarmFallbackOptions { Mode = "InvalidMode" },
|
||||
});
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenAlarmsEnabled_DefaultAutoConfig()
|
||||
{
|
||||
// Default AlarmFallbackOptions (Mode="Auto") must pass validation when alarms are enabled.
|
||||
GatewayOptions options = CloneWithAlarms(
|
||||
ValidOptions(),
|
||||
EnabledAlarmsWithFallback(new AlarmFallbackOptions()));
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Auto")]
|
||||
[InlineData("ForceAlarmManager")]
|
||||
[InlineData("ForceSubtag")]
|
||||
[InlineData("auto")]
|
||||
[InlineData("FORCESUBTAG")]
|
||||
public void Validate_Succeeds_WhenAlarmsEnabled_RecognisedMode(string mode)
|
||||
{
|
||||
AlarmsOptions alarms = mode.Equals("ForceSubtag", StringComparison.OrdinalIgnoreCase)
|
||||
// ForceSubtag needs either UseGalaxyRepository=true (default) or IncludeAttributes.
|
||||
? EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = mode })
|
||||
: EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = mode });
|
||||
GatewayOptions options = CloneWithAlarms(ValidOptions(), alarms);
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenAlarmsEnabled_InvalidMode()
|
||||
{
|
||||
GatewayOptions options = CloneWithAlarms(
|
||||
ValidOptions(),
|
||||
EnabledAlarmsWithFallback(new AlarmFallbackOptions { Mode = "InvalidMode" }));
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Alarms:Fallback") && f.Contains("Mode"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenForceSubtag_NoGalaxyRepository_NoIncludes()
|
||||
{
|
||||
// ForceSubtag without galaxy repository and without IncludeAttributes must fail.
|
||||
GatewayOptions options = CloneWithAlarms(
|
||||
ValidOptions(),
|
||||
EnabledAlarmsWithFallback(new AlarmFallbackOptions
|
||||
{
|
||||
Mode = "ForceSubtag",
|
||||
Discovery = new AlarmDiscoveryOptions
|
||||
{
|
||||
UseGalaxyRepository = false,
|
||||
IncludeAttributes = [],
|
||||
},
|
||||
}));
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("ForceSubtag") && f.Contains("Discovery"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenForceSubtag_NoGalaxyRepository_WithIncludes()
|
||||
{
|
||||
// ForceSubtag without galaxy repository is allowed when IncludeAttributes is non-empty.
|
||||
GatewayOptions options = CloneWithAlarms(
|
||||
ValidOptions(),
|
||||
EnabledAlarmsWithFallback(new AlarmFallbackOptions
|
||||
{
|
||||
Mode = "ForceSubtag",
|
||||
Discovery = new AlarmDiscoveryOptions
|
||||
{
|
||||
UseGalaxyRepository = false,
|
||||
IncludeAttributes = ["attr1"],
|
||||
},
|
||||
}));
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenForceSubtag_WithGalaxyRepository()
|
||||
{
|
||||
// ForceSubtag + UseGalaxyRepository=true (default) must pass even without IncludeAttributes.
|
||||
GatewayOptions options = CloneWithAlarms(
|
||||
ValidOptions(),
|
||||
EnabledAlarmsWithFallback(new AlarmFallbackOptions
|
||||
{
|
||||
Mode = "ForceSubtag",
|
||||
Discovery = new AlarmDiscoveryOptions { UseGalaxyRepository = true },
|
||||
}));
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))]
|
||||
[InlineData(-1, nameof(AlarmFallbackOptions.ConsecutiveFailureThreshold))]
|
||||
public void Validate_Fails_WhenConsecutiveFailureThresholdBelowOne(int value, string keyPart)
|
||||
{
|
||||
GatewayOptions options = CloneWithAlarms(
|
||||
ValidOptions(),
|
||||
EnabledAlarmsWithFallback(new AlarmFallbackOptions { ConsecutiveFailureThreshold = value }));
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains(keyPart));
|
||||
_ = keyPart; // suppress unused-param warning
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))]
|
||||
[InlineData(-5, nameof(AlarmFallbackOptions.FailbackProbeIntervalSeconds))]
|
||||
public void Validate_Fails_WhenFailbackProbeIntervalSecondsBelowOne(int value, string keyPart)
|
||||
{
|
||||
GatewayOptions options = CloneWithAlarms(
|
||||
ValidOptions(),
|
||||
EnabledAlarmsWithFallback(new AlarmFallbackOptions { FailbackProbeIntervalSeconds = value }));
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains(keyPart));
|
||||
_ = keyPart;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, nameof(AlarmFallbackOptions.FailbackStableProbes))]
|
||||
[InlineData(-1, nameof(AlarmFallbackOptions.FailbackStableProbes))]
|
||||
public void Validate_Fails_WhenFailbackStableProbesBelowOne(int value, string keyPart)
|
||||
{
|
||||
GatewayOptions options = CloneWithAlarms(
|
||||
ValidOptions(),
|
||||
EnabledAlarmsWithFallback(new AlarmFallbackOptions { FailbackStableProbes = value }));
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains(keyPart));
|
||||
_ = keyPart;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Pure mapper tests for <see cref="GalaxyRepository.MapAlarmRow"/>. These assert the
|
||||
/// FullTagReference / SourceObjectReference derivation produced by
|
||||
/// <c>AlarmAttributesSql</c> without touching a database: the SQL projects
|
||||
/// <c>tag_name</c> as the source object and <c>tag_name + '.' + attribute_name</c> as
|
||||
/// the full reference, exactly as <c>AttributesSql</c> does.
|
||||
/// </summary>
|
||||
public sealed class GalaxyAlarmAttributeMappingTests
|
||||
{
|
||||
/// <summary>Verifies the mapper copies both projected columns onto the row.</summary>
|
||||
[Fact]
|
||||
public void MapAlarmRow_CopiesProjectedColumns()
|
||||
{
|
||||
GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(
|
||||
fullTagReference: "Tank01.Level.HiHi",
|
||||
sourceObjectReference: "Tank01");
|
||||
|
||||
Assert.Equal("Tank01.Level.HiHi", row.FullTagReference);
|
||||
Assert.Equal("Tank01", row.SourceObjectReference);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="GalaxyAlarmAttributeRow.AckCommentSubtag"/> is always empty:
|
||||
/// the schema does not expose an ack-comment address, so the watch-list resolver
|
||||
/// composes it later from configuration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapAlarmRow_LeavesAckCommentSubtagEmpty()
|
||||
{
|
||||
GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(
|
||||
fullTagReference: "Tank01.Level.HiHi",
|
||||
sourceObjectReference: "Tank01");
|
||||
|
||||
Assert.Equal(string.Empty, row.AckCommentSubtag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the SourceObjectReference is the owning object (the SQL <c>tag_name</c>),
|
||||
/// i.e. the segment that precedes the first attribute dot in the full reference, even
|
||||
/// when the attribute itself is a multi-segment extension path.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Tank01", "Level.HiHi", "Tank01.Level.HiHi")]
|
||||
[InlineData("Pump_001", "Speed", "Pump_001.Speed")]
|
||||
[InlineData("TestAlarm001", "Alarm001", "TestAlarm001.Alarm001")]
|
||||
public void MapAlarmRow_SourceObjectIsSegmentBeforeFirstAttributeDot(
|
||||
string tagName,
|
||||
string attributeName,
|
||||
string expectedFullReference)
|
||||
{
|
||||
// Mirror the AlarmAttributesSql projection: full_tag_reference = tag_name + '.' + attribute_name.
|
||||
string fullTagReference = tagName + "." + attributeName;
|
||||
|
||||
GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(fullTagReference, tagName);
|
||||
|
||||
Assert.Equal(expectedFullReference, row.FullTagReference);
|
||||
Assert.Equal(tagName, row.SourceObjectReference);
|
||||
Assert.Equal(row.FullTagReference, row.SourceObjectReference + "." + attributeName);
|
||||
}
|
||||
}
|
||||
@@ -378,6 +378,10 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("GetAttributesAsync should not be reached");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||
=> throw new InvalidOperationException("GetAlarmAttributesAsync should not be reached");
|
||||
}
|
||||
|
||||
/// <summary>Snapshot store whose <see cref="SaveAsync"/> cancels the token mid-save.</summary>
|
||||
@@ -465,6 +469,10 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
|
||||
GetAttributesCount++;
|
||||
return Task.FromResult(_attributes);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<GalaxyAlarmAttributeRow>());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -518,6 +526,10 @@ public sealed class GalaxyHierarchyCacheTests : IDisposable
|
||||
GetAttributesCount++;
|
||||
throw toThrow;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||
=> throw toThrow;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Metrics;
|
||||
@@ -63,6 +64,98 @@ public sealed class GatewayMetricsTests
|
||||
Assert.Equal("depth", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="GatewayMetrics.AlarmProviderSwitched"/> increments
|
||||
/// <c>mxgateway.alarms.provider_switches</c> by one with the expected from/to/reason tags.
|
||||
/// The listener filters by the specific <see cref="System.Diagnostics.Metrics.Meter"/> instance
|
||||
/// to avoid cross-talk between parallel tests (Tests-027).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AlarmProviderSwitched_IncrementsCounterWithExpectedTags()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
using MeterListener listener = new();
|
||||
|
||||
long capturedValue = 0;
|
||||
string? capturedFrom = null;
|
||||
string? capturedTo = null;
|
||||
string? capturedReason = null;
|
||||
|
||||
listener.InstrumentPublished = (instrument, meterListener) =>
|
||||
{
|
||||
if (ReferenceEquals(instrument.Meter, metrics.Meter)
|
||||
&& instrument.Name == "mxgateway.alarms.provider_switches")
|
||||
{
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>(
|
||||
(instrument, measurement, tags, _) =>
|
||||
{
|
||||
if (!ReferenceEquals(instrument.Meter, metrics.Meter)
|
||||
|| instrument.Name != "mxgateway.alarms.provider_switches")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
capturedValue += measurement;
|
||||
foreach (KeyValuePair<string, object?> tag in tags)
|
||||
{
|
||||
switch (tag.Key)
|
||||
{
|
||||
case "from": capturedFrom = tag.Value as string; break;
|
||||
case "to": capturedTo = tag.Value as string; break;
|
||||
case "reason": capturedReason = tag.Value as string; break;
|
||||
}
|
||||
}
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
metrics.AlarmProviderSwitched(1, 2, "test");
|
||||
|
||||
Assert.Equal(1, capturedValue);
|
||||
Assert.Equal("1", capturedFrom);
|
||||
Assert.Equal("2", capturedTo);
|
||||
Assert.Equal("test", capturedReason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="GatewayMetrics.AlarmProviderSwitched"/> updates the
|
||||
/// <c>mxgateway.alarms.provider_mode</c> observable gauge to the <paramref name="toMode"/> value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AlarmProviderSwitched_UpdatesProviderModeGauge()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
using MeterListener listener = new();
|
||||
|
||||
int? capturedMode = null;
|
||||
|
||||
listener.InstrumentPublished = (instrument, meterListener) =>
|
||||
{
|
||||
if (ReferenceEquals(instrument.Meter, metrics.Meter)
|
||||
&& instrument.Name == "mxgateway.alarms.provider_mode")
|
||||
{
|
||||
meterListener.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<int>(
|
||||
(instrument, measurement, _, _) =>
|
||||
{
|
||||
if (ReferenceEquals(instrument.Meter, metrics.Meter)
|
||||
&& instrument.Name == "mxgateway.alarms.provider_mode")
|
||||
{
|
||||
capturedMode = measurement;
|
||||
}
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
metrics.AlarmProviderSwitched(1, 2, "test");
|
||||
listener.RecordObservableInstruments();
|
||||
|
||||
Assert.Equal(2, capturedMode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that removing session events only affects that session.</summary>
|
||||
[Fact]
|
||||
public void RemoveSessionEvents_RemovesOnlyThatSession()
|
||||
|
||||
@@ -64,6 +64,41 @@ public sealed class AlarmDispatcherTests
|
||||
Assert.Equal(ts, body.TransitionTimestamp.ToDateTime());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a transition enqueued via the subtag fallback
|
||||
/// (<c>degraded: true</c>) is marked <see cref="OnAlarmTransitionEvent.Degraded"/>
|
||||
/// with <see cref="AlarmProviderMode.Subtag"/>, while the default path
|
||||
/// stays on the alarmmgr parity contract.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EnqueueTransition_WhenDegraded_MarksDegradedAndSubtagProvider()
|
||||
{
|
||||
MxAccessEventQueue queue = new MxAccessEventQueue();
|
||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(queue, new MxAccessEventMapper());
|
||||
sink.Attach(new object(), SessionId);
|
||||
|
||||
DateTime ts = new DateTime(2026, 5, 1, 17, 26, 14, 709, DateTimeKind.Utc);
|
||||
sink.EnqueueTransition(
|
||||
alarmFullReference: "Galaxy!TestArea.TestMachine_001.TestAlarm001",
|
||||
sourceObjectReference: "TestMachine_001.TestAlarm001",
|
||||
alarmTypeName: "DSC",
|
||||
transitionKind: AlarmTransitionKind.Raise,
|
||||
severity: 500,
|
||||
originalRaiseTimestampUtc: null,
|
||||
transitionTimestampUtc: ts,
|
||||
operatorUser: string.Empty,
|
||||
operatorComment: string.Empty,
|
||||
category: "TestArea",
|
||||
description: string.Empty,
|
||||
degraded: true);
|
||||
|
||||
Assert.Equal(1, queue.Count);
|
||||
Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent));
|
||||
OnAlarmTransitionEvent body = workerEvent!.Event.OnAlarmTransition;
|
||||
Assert.True(body.Degraded);
|
||||
Assert.Equal(AlarmProviderMode.Subtag, body.SourceProvider);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unchanged alarm states do not emit transitions.</summary>
|
||||
[Fact]
|
||||
public void OnTransition_WithConsecutiveUnchangedState_DoesNotEmitTransition()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="SyntheticAlarmGuid"/>: the subtag-fallback
|
||||
/// path derives a deterministic GUID from the alarm reference, so identical
|
||||
/// references must collide and distinct references must not.
|
||||
/// </summary>
|
||||
public sealed class SyntheticAlarmGuidTests
|
||||
{
|
||||
/// <summary>Verifies the same reference yields the same GUID.</summary>
|
||||
[Fact]
|
||||
public void SameReference_SameGuid() =>
|
||||
Assert.Equal(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.C"));
|
||||
|
||||
/// <summary>Verifies distinct references yield distinct GUIDs.</summary>
|
||||
[Fact]
|
||||
public void DifferentReference_DifferentGuid() =>
|
||||
Assert.NotEqual(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.D"));
|
||||
|
||||
/// <summary>Verifies a reference produces a non-empty GUID.</summary>
|
||||
[Fact]
|
||||
public void Reference_ProducesNonEmptyGuid() =>
|
||||
Assert.NotEqual(Guid.Empty, SyntheticAlarmGuid.ForReference("A.B.C"));
|
||||
}
|
||||
@@ -86,6 +86,12 @@ public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
|
||||
/// <param name="operatorComment">The operator's comment, if any.</param>
|
||||
/// <param name="category">The alarm category.</param>
|
||||
/// <param name="description">The alarm description.</param>
|
||||
/// <param name="degraded">
|
||||
/// <see langword="true"/> when the transition was synthesized by the
|
||||
/// subtag-provider fallback rather than the native alarmmgr path.
|
||||
/// Defaults to <see langword="false"/> so existing alarmmgr callers
|
||||
/// compile unchanged and stay on the parity (alarmmgr) path.
|
||||
/// </param>
|
||||
internal void EnqueueTransition(
|
||||
string alarmFullReference,
|
||||
string sourceObjectReference,
|
||||
@@ -97,10 +103,16 @@ public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
|
||||
string operatorUser,
|
||||
string operatorComment,
|
||||
string category,
|
||||
string description)
|
||||
string description,
|
||||
bool degraded = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Degraded transitions come from the subtag fallback; the native
|
||||
// alarmmgr (wnwrap) path stays degraded=false / ALARMMGR for parity.
|
||||
AlarmProviderMode sourceProvider = degraded
|
||||
? AlarmProviderMode.Subtag
|
||||
: AlarmProviderMode.Alarmmgr;
|
||||
MxEvent mxEvent = eventMapper.CreateOnAlarmTransition(
|
||||
sessionId,
|
||||
alarmFullReference,
|
||||
@@ -114,7 +126,9 @@ public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
|
||||
operatorComment,
|
||||
category,
|
||||
description,
|
||||
statuses: null);
|
||||
statuses: null,
|
||||
degraded: degraded,
|
||||
sourceProvider: sourceProvider);
|
||||
eventQueue.Enqueue(mxEvent);
|
||||
}
|
||||
catch (Exception exception)
|
||||
|
||||
@@ -124,6 +124,15 @@ public sealed class MxAccessEventMapper
|
||||
/// <param name="category">Alarm taxonomy bucket from the Galaxy template.</param>
|
||||
/// <param name="description">Human-readable alarm description.</param>
|
||||
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
|
||||
/// <param name="degraded">
|
||||
/// <see langword="true"/> when this transition was synthesized by the
|
||||
/// subtag-provider fallback rather than the native alarmmgr path.
|
||||
/// Defaults to <see langword="false"/> to preserve alarmmgr parity.
|
||||
/// </param>
|
||||
/// <param name="sourceProvider">
|
||||
/// The alarm provider that sourced this transition. Defaults to
|
||||
/// <see cref="AlarmProviderMode.Alarmmgr"/> for the native path.
|
||||
/// </param>
|
||||
public MxEvent CreateOnAlarmTransition(
|
||||
string sessionId,
|
||||
string alarmFullReference,
|
||||
@@ -137,7 +146,9 @@ public sealed class MxAccessEventMapper
|
||||
string operatorComment,
|
||||
string category,
|
||||
string description,
|
||||
Array? statuses)
|
||||
Array? statuses,
|
||||
bool degraded = false,
|
||||
AlarmProviderMode sourceProvider = AlarmProviderMode.Alarmmgr)
|
||||
{
|
||||
MxEvent mxEvent = CreateBaseEvent(
|
||||
MxEventFamily.OnAlarmTransition,
|
||||
@@ -159,6 +170,8 @@ public sealed class MxAccessEventMapper
|
||||
OperatorComment = operatorComment ?? string.Empty,
|
||||
Category = category ?? string.Empty,
|
||||
Description = description ?? string.Empty,
|
||||
Degraded = degraded,
|
||||
SourceProvider = sourceProvider,
|
||||
};
|
||||
if (originalRaiseTimestampUtc is { } orts)
|
||||
{
|
||||
|
||||
@@ -37,4 +37,12 @@ public sealed class MxAlarmSnapshotRecord
|
||||
public string OperatorName { get; set; } = string.Empty;
|
||||
/// <summary>Gets or sets the alarm comment.</summary>
|
||||
public string AlarmComment { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this record was synthesized
|
||||
/// by the subtag-provider fallback rather than emitted by the native
|
||||
/// alarmmgr (wnwrap) path. Default <see langword="false"/> preserves
|
||||
/// parity for the alarmmgr path; the subtag fallback sets it to
|
||||
/// <see langword="true"/>.
|
||||
/// </summary>
|
||||
public bool Degraded { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Derives a deterministic synthetic <see cref="Guid"/> from an alarm
|
||||
/// reference for the subtag-provider fallback path, which has no native
|
||||
/// MxAccess alarm GUID. Hashing the reference yields a stable identity so
|
||||
/// repeated transitions for the same alarm reference correlate downstream
|
||||
/// (acknowledge, snapshot, OPC UA mapping) without an alarmmgr-supplied GUID.
|
||||
/// </summary>
|
||||
public static class SyntheticAlarmGuid
|
||||
{
|
||||
/// <summary>
|
||||
/// Produces a stable <see cref="Guid"/> for the given alarm reference.
|
||||
/// The same reference always maps to the same GUID; distinct references
|
||||
/// map to distinct GUIDs with overwhelming probability.
|
||||
/// </summary>
|
||||
/// <param name="reference">
|
||||
/// The fully-qualified alarm reference (for example
|
||||
/// <c>"Galaxy!Area.Tag.HiHi"</c>). Treated as UTF-8 bytes.
|
||||
/// </param>
|
||||
/// <returns>A deterministic, non-empty GUID derived from the reference.</returns>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// Thrown when <paramref name="reference"/> is <see langword="null"/>.
|
||||
/// </exception>
|
||||
public static Guid ForReference(string reference)
|
||||
{
|
||||
if (reference is null) throw new ArgumentNullException(nameof(reference));
|
||||
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(reference);
|
||||
|
||||
// MD5 is used purely for a stable, non-cryptographic identity mapping
|
||||
// (reference -> 16-byte GUID), never for security. Its 128-bit output
|
||||
// fits a GUID exactly, which is why it is preferred here.
|
||||
using MD5 md5 = MD5.Create();
|
||||
byte[] hash = md5.ComputeHash(bytes);
|
||||
return new Guid(hash);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user