fix(alarms): truncation-safe transitions; perf: single-pass alarm parse; configurable poll cadence
This commit is contained in:
@@ -135,6 +135,56 @@ alarm state is gateway-wide, not session-scoped — every client wants the same
|
|||||||
current set plus updates, and forcing each to own a worker would multiply AVEVA
|
current set plus updates, and forcing each to own a worker would multiply AVEVA
|
||||||
polling load for no benefit.
|
polling load for no benefit.
|
||||||
|
|
||||||
|
### Alarms — a capped snapshot fetch never implies a clear
|
||||||
|
|
||||||
|
Decision (2026-08-15): when the worker's `GetXmlCurrentAlarms2` fetch comes back
|
||||||
|
holding exactly `MxGateway:Alarms:MaxAlarmsPerFetch` records, the worker treats
|
||||||
|
the snapshot as **truncated** and merges it into the retained snapshot instead
|
||||||
|
of replacing it. Alarms the capped reply did carry update normally; alarms it
|
||||||
|
had no room to mention are retained untouched.
|
||||||
|
|
||||||
|
The COM API caps its reply at `maxAlmCnt` and exposes no "more available" flag,
|
||||||
|
so a reply sitting exactly on the cap is indistinguishable from a galaxy that
|
||||||
|
happens to hold exactly that many active alarms. Both are treated as truncated,
|
||||||
|
because the two error directions are not symmetric.
|
||||||
|
|
||||||
|
Nothing in the worker emits a Clear transition. The clear is an **inference**:
|
||||||
|
`WnWrapAlarmConsumer.ComputeTransitions` produces no transition for an alarm
|
||||||
|
that disappears from the snapshot, and `GatewayAlarmMonitor.ApplyReconcile`
|
||||||
|
later diffs its cache against `SnapshotActiveAlarms()` and broadcasts a Clear
|
||||||
|
for every cached alarm the worker no longer reports. Before this decision, a
|
||||||
|
capped fetch shrank that snapshot, so every alarm past the cap was broadcast as
|
||||||
|
cleared while still standing — a silent, galaxy-wide false clear on exactly the
|
||||||
|
alarm floods where the cap is reached.
|
||||||
|
|
||||||
|
Consequences, and how this sits with the existing failover/reconcile design:
|
||||||
|
|
||||||
|
- **The suppression is an eviction guard, not a transition filter.** It lives in
|
||||||
|
the snapshot update inside `PollOnce`, not in `ComputeTransitions`, which was
|
||||||
|
never going to emit anything for a disappearance. The reconcile/dedup
|
||||||
|
machinery (`_clearedByReconcile` tombstones, the NEXT-03 duplicate-Clear
|
||||||
|
suppression) is untouched: it still sees the same shape of snapshot, only
|
||||||
|
with the truncated poll's unmentionable alarms still present.
|
||||||
|
- **It preserves at-least-once, idempotent application.** The failure mode
|
||||||
|
becomes bounded staleness — a genuinely cleared alarm can linger until the
|
||||||
|
first sub-cap fetch evicts it, and the reconcile then broadcasts its Clear
|
||||||
|
late. A late Clear is repaired by the next complete poll; a Clear that never
|
||||||
|
happened is broadcast to every `StreamAlarms` subscriber and cannot be taken
|
||||||
|
back. Consumers already apply transitions as "set this alarm to this state",
|
||||||
|
so a repeated or delayed Clear is absorbed.
|
||||||
|
- **It does not synthesize anything.** Suppressing an inference is the opposite
|
||||||
|
of inventing an event; no transition is fabricated on a truncated poll.
|
||||||
|
- **Failover is unaffected.** `FailoverAlarmConsumer` selects which
|
||||||
|
`IMxAccessAlarmConsumer` is live; the guard is internal to the wnwrap
|
||||||
|
consumer's own snapshot bookkeeping and changes neither the failure counting
|
||||||
|
that triggers failover nor the subtag standby's snapshot, which is built from
|
||||||
|
a bounded watch-list and has no per-fetch cap to hit.
|
||||||
|
- **Operators get told.** A truncated poll logs a rate-limited (once per
|
||||||
|
minute) `AlarmSnapshotTruncated` warning carrying the cap, the record counts,
|
||||||
|
and the running truncated-fetch total — identifiers and counts only, never
|
||||||
|
tag names, values, limits, or comments. A galaxy that truncates persistently
|
||||||
|
is a configuration problem: raise `MxGateway:Alarms:MaxAlarmsPerFetch`.
|
||||||
|
|
||||||
## Session-Resilience Epic Scope
|
## Session-Resilience Epic Scope
|
||||||
|
|
||||||
Decision (2026-07-09, archreview TST-04; migrated here 2026-08-07 from the retired
|
Decision (2026-07-09, archreview TST-04; migrated here 2026-08-07 from the retired
|
||||||
|
|||||||
@@ -417,6 +417,8 @@ behavior.
|
|||||||
| `MxGateway:Alarms:SubscriptionExpression` | _(empty)_ | AVEVA alarm-subscription expression the monitor subscribes on startup, in canonical `\\<machine>\Galaxy!<area>` form. The literal `Galaxy` provider is correct regardless of the Galaxy database name. When empty and `Enabled` is `true`, the gateway falls back to `\\<MachineName>\Galaxy!<DefaultArea>` if `DefaultArea` is set. |
|
| `MxGateway:Alarms:SubscriptionExpression` | _(empty)_ | AVEVA alarm-subscription expression the monitor subscribes on startup, in canonical `\\<machine>\Galaxy!<area>` form. The literal `Galaxy` provider is correct regardless of the Galaxy database name. When empty and `Enabled` is `true`, the gateway falls back to `\\<MachineName>\Galaxy!<DefaultArea>` if `DefaultArea` is set. |
|
||||||
| `MxGateway:Alarms:DefaultArea` | _(empty)_ | Area name used to compose a default subscription when `SubscriptionExpression` is empty. If both are empty while `Enabled` is `true`, the monitor faults with a configuration diagnostic. |
|
| `MxGateway:Alarms:DefaultArea` | _(empty)_ | Area name used to compose a default subscription when `SubscriptionExpression` is empty. If both are empty while `Enabled` is `true`, the monitor faults with a configuration diagnostic. |
|
||||||
| `MxGateway:Alarms:ReconcileIntervalSeconds` | `30` | How often the monitor reconciles its in-process alarm cache against the worker's authoritative active-alarm snapshot, catching transitions the live poll-and-diff feed missed. Floored at 5 seconds. |
|
| `MxGateway:Alarms:ReconcileIntervalSeconds` | `30` | How often the monitor reconciles its in-process alarm cache against the worker's authoritative active-alarm snapshot, catching transitions the live poll-and-diff feed missed. Floored at 5 seconds. |
|
||||||
|
| `MxGateway:Alarms:PollIntervalMilliseconds` | `500` | Cadence at which the worker's STA polls the AVEVA alarm consumer (`GetXmlCurrentAlarms2`) for the active-alarm snapshot the live feed diffs. Must be `>= 100`: every poll is a COM call plus an XML parse on the same STA that serves reads and writes, so a tighter cadence starves the command path. The gateway conveys the value to the worker via the `MXGATEWAY_ALARM_POLL_INTERVAL_MS` environment variable; a missing or unusable value leaves the worker on the 500 ms default rather than failing the session. |
|
||||||
|
| `MxGateway:Alarms:MaxAlarmsPerFetch` | `1024` | Cap the worker passes to `GetXmlCurrentAlarms2`'s `maxAlmCnt`. Must be `>= 64`. It doubles as the **truncation threshold**: a fetch returning exactly this many records is treated as truncated, because the COM API caps its reply with no "more available" flag. On a truncated poll the worker retains the alarms the capped reply could not mention instead of letting their absence read as a clear, and logs a rate-limited `AlarmSnapshotTruncated` warning (identifiers and counts only). Raise this on galaxies whose steady-state active-alarm count approaches the cap — a galaxy permanently above it holds stale entries in the snapshot until a sub-cap poll. Conveyed to the worker via the `MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH` environment variable; a missing or unusable value leaves the worker on the 1024 default. |
|
||||||
|
|
||||||
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
||||||
`StreamAlarms` are session-less RPCs served by the monitor.
|
`StreamAlarms` are session-less RPCs served by the monitor.
|
||||||
|
|||||||
@@ -46,6 +46,29 @@ public sealed class AlarmsOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReconcileIntervalSeconds { get; init; } = 30;
|
public int ReconcileIntervalSeconds { get; init; } = 30;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cadence at which the worker's STA polls the AVEVA alarm consumer
|
||||||
|
/// (<c>GetXmlCurrentAlarms2</c>) for the current active-alarm snapshot.
|
||||||
|
/// Default 500 ms; must be at least 100 ms. Every poll is a COM call
|
||||||
|
/// plus an XML parse on the STA that also serves reads and writes, so
|
||||||
|
/// driving it below 100 ms starves the command path. Conveyed to the
|
||||||
|
/// worker through the <c>MXGATEWAY_ALARM_POLL_INTERVAL_MS</c>
|
||||||
|
/// environment variable.
|
||||||
|
/// </summary>
|
||||||
|
public int PollIntervalMilliseconds { get; init; } = 500;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cap the worker passes to <c>GetXmlCurrentAlarms2</c>'s
|
||||||
|
/// <c>maxAlmCnt</c> argument. Default 1024; must be at least 64. A
|
||||||
|
/// fetch that comes back holding exactly this many records is treated
|
||||||
|
/// as truncated: the worker keeps the alarms the capped fetch could
|
||||||
|
/// not mention in its snapshot rather than letting their absence read
|
||||||
|
/// as a clear. Raise it on galaxies whose steady-state active-alarm
|
||||||
|
/// count approaches the cap. Conveyed to the worker through the
|
||||||
|
/// <c>MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH</c> environment variable.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxAlarmsPerFetch { get; init; } = 1024;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
|
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
|
||||||
/// operating mode, failure-detection thresholds, discovery, and subtag
|
/// operating mode, failure-detection thresholds, discovery, and subtag
|
||||||
|
|||||||
@@ -414,8 +414,26 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
|||||||
|
|
||||||
private static readonly string[] ValidAlarmFallbackModes = ["Auto", "ForceAlarmManager", "ForceSubtag"];
|
private static readonly string[] ValidAlarmFallbackModes = ["Auto", "ForceAlarmManager", "ForceSubtag"];
|
||||||
|
|
||||||
|
private const int MinimumAlarmPollIntervalMilliseconds = 100;
|
||||||
|
private const int MinimumMaxAlarmsPerFetch = 64;
|
||||||
|
|
||||||
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
|
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
|
// Validated regardless of Enabled: both values are stamped onto every
|
||||||
|
// worker launch environment, so a bad value is a misconfiguration even
|
||||||
|
// before the central monitor is switched on.
|
||||||
|
if (options.PollIntervalMilliseconds < MinimumAlarmPollIntervalMilliseconds)
|
||||||
|
{
|
||||||
|
builder.Add(
|
||||||
|
$"MxGateway:Alarms:PollIntervalMilliseconds must be greater than or equal to {MinimumAlarmPollIntervalMilliseconds}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.MaxAlarmsPerFetch < MinimumMaxAlarmsPerFetch)
|
||||||
|
{
|
||||||
|
builder.Add(
|
||||||
|
$"MxGateway:Alarms:MaxAlarmsPerFetch must be greater than or equal to {MinimumMaxAlarmsPerFetch}.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.Enabled)
|
if (!options.Enabled)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -29,11 +29,27 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
|
|||||||
public const string WorkerWriteCompletionWaitEnvironmentVariableName =
|
public const string WorkerWriteCompletionWaitEnvironmentVariableName =
|
||||||
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
|
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Conveys MxGateway:Alarms:PollIntervalMilliseconds to the worker: the
|
||||||
|
/// cadence at which the worker's STA polls the AVEVA alarm consumer.
|
||||||
|
/// </summary>
|
||||||
|
public const string WorkerAlarmPollIntervalEnvironmentVariableName =
|
||||||
|
"MXGATEWAY_ALARM_POLL_INTERVAL_MS";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Conveys MxGateway:Alarms:MaxAlarmsPerFetch to the worker: the cap
|
||||||
|
/// passed to GetXmlCurrentAlarms2, which is also the record count at
|
||||||
|
/// which the worker treats a fetch as truncated.
|
||||||
|
/// </summary>
|
||||||
|
public const string WorkerMaxAlarmsPerFetchEnvironmentVariableName =
|
||||||
|
"MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH";
|
||||||
|
|
||||||
private readonly IWorkerProcessFactory _processFactory;
|
private readonly IWorkerProcessFactory _processFactory;
|
||||||
private readonly IWorkerStartupProbe _startupProbe;
|
private readonly IWorkerStartupProbe _startupProbe;
|
||||||
private readonly GatewayMetrics _metrics;
|
private readonly GatewayMetrics _metrics;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly WorkerOptions _workerOptions;
|
private readonly WorkerOptions _workerOptions;
|
||||||
|
private readonly AlarmsOptions _alarmsOptions;
|
||||||
private readonly ILogger<WorkerProcessLauncher> _logger;
|
private readonly ILogger<WorkerProcessLauncher> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -59,6 +75,7 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
|
|||||||
ArgumentNullException.ThrowIfNull(metrics);
|
ArgumentNullException.ThrowIfNull(metrics);
|
||||||
|
|
||||||
_workerOptions = gatewayOptions.Value.Worker;
|
_workerOptions = gatewayOptions.Value.Worker;
|
||||||
|
_alarmsOptions = gatewayOptions.Value.Alarms;
|
||||||
_processFactory = processFactory;
|
_processFactory = processFactory;
|
||||||
_startupProbe = startupProbe;
|
_startupProbe = startupProbe;
|
||||||
_metrics = metrics;
|
_metrics = metrics;
|
||||||
@@ -185,6 +202,10 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher
|
|||||||
_workerOptions.PipeConnectAttemptTimeoutMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
_workerOptions.PipeConnectAttemptTimeoutMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
startInfo.Environment[WorkerWriteCompletionWaitEnvironmentVariableName] =
|
startInfo.Environment[WorkerWriteCompletionWaitEnvironmentVariableName] =
|
||||||
_workerOptions.WriteCompletionWaitMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
_workerOptions.WriteCompletionWaitMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
startInfo.Environment[WorkerAlarmPollIntervalEnvironmentVariableName] =
|
||||||
|
_alarmsOptions.PollIntervalMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
startInfo.Environment[WorkerMaxAlarmsPerFetchEnvironmentVariableName] =
|
||||||
|
_alarmsOptions.MaxAlarmsPerFetch.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
commandLine = new WorkerProcessCommandLine(executablePath, arguments);
|
commandLine = new WorkerProcessCommandLine(executablePath, arguments);
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,103 @@ public sealed class GatewayOptionsValidatorTests
|
|||||||
Tls = source.Tls,
|
Tls = source.Tls,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Verifies the alarm poll cadence and per-fetch cap defaults pass validation.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Succeeds_WithDefaultAlarmPollCadenceAndFetchCap()
|
||||||
|
{
|
||||||
|
AlarmsOptions alarms = new();
|
||||||
|
Assert.Equal(500, alarms.PollIntervalMilliseconds);
|
||||||
|
Assert.Equal(1024, alarms.MaxAlarmsPerFetch);
|
||||||
|
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator()
|
||||||
|
.Validate(null, CloneWithAlarms(ValidOptions(), alarms));
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A poll cadence below the 100 ms floor must fail validation. Both
|
||||||
|
/// values are stamped onto every worker launch environment, so they
|
||||||
|
/// are validated whether or not the central alarm monitor is enabled.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pollIntervalMilliseconds">Cadence under test.</param>
|
||||||
|
/// <param name="alarmsEnabled">Whether the central alarm monitor is on.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(99, false)]
|
||||||
|
[InlineData(0, false)]
|
||||||
|
[InlineData(-1, false)]
|
||||||
|
[InlineData(99, true)]
|
||||||
|
public void Validate_Fails_WhenAlarmPollIntervalBelowFloor(
|
||||||
|
int pollIntervalMilliseconds,
|
||||||
|
bool alarmsEnabled)
|
||||||
|
{
|
||||||
|
GatewayOptions options = CloneWithAlarms(
|
||||||
|
ValidOptions(),
|
||||||
|
new AlarmsOptions
|
||||||
|
{
|
||||||
|
Enabled = alarmsEnabled,
|
||||||
|
DefaultArea = "Galaxy",
|
||||||
|
PollIntervalMilliseconds = pollIntervalMilliseconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
|
||||||
|
Assert.False(result.Succeeded);
|
||||||
|
Assert.Contains(
|
||||||
|
result.Failures!,
|
||||||
|
f => f.Contains("MxGateway:Alarms:PollIntervalMilliseconds", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A per-fetch cap below the 64-record floor must fail validation. The
|
||||||
|
/// cap doubles as the truncation-detection threshold in the worker, so
|
||||||
|
/// a tiny cap would make almost every fetch read as truncated.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="maxAlarmsPerFetch">Cap under test.</param>
|
||||||
|
/// <param name="alarmsEnabled">Whether the central alarm monitor is on.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(63, false)]
|
||||||
|
[InlineData(0, false)]
|
||||||
|
[InlineData(-1, false)]
|
||||||
|
[InlineData(63, true)]
|
||||||
|
public void Validate_Fails_WhenMaxAlarmsPerFetchBelowFloor(
|
||||||
|
int maxAlarmsPerFetch,
|
||||||
|
bool alarmsEnabled)
|
||||||
|
{
|
||||||
|
GatewayOptions options = CloneWithAlarms(
|
||||||
|
ValidOptions(),
|
||||||
|
new AlarmsOptions
|
||||||
|
{
|
||||||
|
Enabled = alarmsEnabled,
|
||||||
|
DefaultArea = "Galaxy",
|
||||||
|
MaxAlarmsPerFetch = maxAlarmsPerFetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
|
||||||
|
Assert.False(result.Succeeded);
|
||||||
|
Assert.Contains(
|
||||||
|
result.Failures!,
|
||||||
|
f => f.Contains("MxGateway:Alarms:MaxAlarmsPerFetch", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies the floor values themselves are accepted.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Succeeds_AtAlarmPollCadenceAndFetchCapFloors()
|
||||||
|
{
|
||||||
|
GatewayOptions options = CloneWithAlarms(
|
||||||
|
ValidOptions(),
|
||||||
|
new AlarmsOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
DefaultArea = "Galaxy",
|
||||||
|
PollIntervalMilliseconds = 100,
|
||||||
|
MaxAlarmsPerFetch = 64,
|
||||||
|
});
|
||||||
|
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies an invalid fallback mode is not validated when alarms are disabled.</summary>
|
/// <summary>Verifies an invalid fallback mode is not validated when alarms are disabled.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated()
|
public void Validate_Succeeds_WhenAlarmsDisabled_FallbackNotValidated()
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ public sealed class WorkerProcessLauncherTests
|
|||||||
"1500",
|
"1500",
|
||||||
processFactory.LastStartInfo.Environment[
|
processFactory.LastStartInfo.Environment[
|
||||||
WorkerProcessLauncher.WorkerWriteCompletionWaitEnvironmentVariableName]);
|
WorkerProcessLauncher.WorkerWriteCompletionWaitEnvironmentVariableName]);
|
||||||
|
// MxGateway:Alarms defaults reach the worker's alarm poll loop and its
|
||||||
|
// GetXmlCurrentAlarms2 cap (which is also its truncation threshold)
|
||||||
|
// through the launch environment, not the command line.
|
||||||
|
Assert.Equal(
|
||||||
|
"500",
|
||||||
|
processFactory.LastStartInfo.Environment[
|
||||||
|
WorkerProcessLauncher.WorkerAlarmPollIntervalEnvironmentVariableName]);
|
||||||
|
Assert.Equal(
|
||||||
|
"1024",
|
||||||
|
processFactory.LastStartInfo.Environment[
|
||||||
|
WorkerProcessLauncher.WorkerMaxAlarmsPerFetchEnvironmentVariableName]);
|
||||||
Assert.DoesNotContain(Nonce, handle.CommandLine.ToString(), StringComparison.Ordinal);
|
Assert.DoesNotContain(Nonce, handle.CommandLine.ToString(), StringComparison.Ordinal);
|
||||||
Assert.DoesNotContain(Nonce, string.Join(" ", handle.CommandLine.Arguments), StringComparison.Ordinal);
|
Assert.DoesNotContain(Nonce, string.Join(" ", handle.CommandLine.Arguments), StringComparison.Ordinal);
|
||||||
Assert.False(pipeReservation.DisposeCalled);
|
Assert.False(pipeReservation.DisposeCalled);
|
||||||
|
|||||||
@@ -317,6 +317,346 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
|||||||
Assert.Equal(MxAlarmStateKind.AckAlm, changedTransition.Record.State);
|
Assert.Equal(MxAlarmStateKind.AckAlm, changedTransition.Record.State);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Truncation cliff (Task 23). GetXmlCurrentAlarms2 caps its reply at
|
||||||
|
// maxAlmCnt with no "more available" flag. Before the guard, a capped fetch
|
||||||
|
// shrank the retained snapshot, and every alarm past the cap vanished from
|
||||||
|
// SnapshotActiveAlarms — which the gateway's reconcile pass reads as a
|
||||||
|
// Clear and broadcasts to every StreamAlarms subscriber.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A fetch that comes back holding exactly the cap must be treated as
|
||||||
|
/// truncated; anything below the cap must not.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fetchedRecordCount">Records the reply carried.</param>
|
||||||
|
/// <param name="maxAlarmsPerFetch">The cap passed to the fetch.</param>
|
||||||
|
/// <param name="expected">Whether the reply must read as truncated.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1024, 1024, true)]
|
||||||
|
[InlineData(1023, 1024, false)]
|
||||||
|
[InlineData(0, 1024, false)]
|
||||||
|
[InlineData(64, 64, true)]
|
||||||
|
public void IsTruncatedFetch_AtOrAboveCap_IsTruncated(
|
||||||
|
int fetchedRecordCount,
|
||||||
|
int maxAlarmsPerFetch,
|
||||||
|
bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
expected,
|
||||||
|
WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// THE truncation-correctness test. A 1024-record fetch against a
|
||||||
|
/// 1024 cap that does not mention a known-active alarm must NOT evict
|
||||||
|
/// that alarm from the retained snapshot: the snapshot is what
|
||||||
|
/// <see cref="WnWrapAlarmConsumer.SnapshotActiveAlarms"/> returns and
|
||||||
|
/// what the gateway's reconcile diffs its cache against, so an
|
||||||
|
/// eviction here is a Clear broadcast for an alarm that never cleared.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ApplySnapshotUpdate_WhenFetchTruncated_RetainsAlarmMissingFromCappedFetch()
|
||||||
|
{
|
||||||
|
const int Cap = 1024;
|
||||||
|
Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");
|
||||||
|
|
||||||
|
string xml = BuildAlarmXml(Cap);
|
||||||
|
Dictionary<Guid, MxAlarmSnapshotRecord> next =
|
||||||
|
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
|
||||||
|
|
||||||
|
Assert.Equal(Cap, fetchedRecordCount);
|
||||||
|
Assert.Equal(Cap, next.Count);
|
||||||
|
Assert.False(next.ContainsKey(missingGuid));
|
||||||
|
Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
|
||||||
|
|
||||||
|
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
|
||||||
|
{
|
||||||
|
[missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The diff itself never emits a Clear for a disappearance — the Clear
|
||||||
|
// is the eviction, one level up. Pin both halves.
|
||||||
|
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
||||||
|
WnWrapAlarmConsumer.ComputeTransitions(snapshot, next);
|
||||||
|
Assert.DoesNotContain(transitions, t => t.Record.AlarmGuid == missingGuid);
|
||||||
|
|
||||||
|
WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: true);
|
||||||
|
|
||||||
|
// NO Clear: the alarm the capped fetch had no room to mention survives,
|
||||||
|
// so the gateway's reconcile still sees it active.
|
||||||
|
Assert.True(snapshot.ContainsKey(missingGuid));
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackAlm, snapshot[missingGuid].State);
|
||||||
|
Assert.Equal(Cap + 1, snapshot.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The control case: the same missing alarm against a sub-cap fetch
|
||||||
|
/// must still be evicted, because a complete fetch IS authoritative
|
||||||
|
/// about absence. Without this, the truncation guard would have
|
||||||
|
/// silently disabled clears altogether.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ApplySnapshotUpdate_WhenFetchBelowCap_EvictsAlarmMissingFromFetch()
|
||||||
|
{
|
||||||
|
const int Cap = 1024;
|
||||||
|
Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");
|
||||||
|
|
||||||
|
string xml = BuildAlarmXml(Cap - 1);
|
||||||
|
Dictionary<Guid, MxAlarmSnapshotRecord> next =
|
||||||
|
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
|
||||||
|
|
||||||
|
Assert.Equal(Cap - 1, fetchedRecordCount);
|
||||||
|
Assert.False(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
|
||||||
|
|
||||||
|
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
|
||||||
|
{
|
||||||
|
[missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
|
||||||
|
};
|
||||||
|
|
||||||
|
WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: false);
|
||||||
|
|
||||||
|
// Clear as today: the alarm drops out of the snapshot, and the
|
||||||
|
// gateway's reconcile turns that absence into a Clear.
|
||||||
|
Assert.False(snapshot.ContainsKey(missingGuid));
|
||||||
|
Assert.Equal(Cap - 1, snapshot.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A truncated fetch must still apply the alarms it DID carry — the
|
||||||
|
/// guard suppresses eviction, not the update. An alarm whose state
|
||||||
|
/// changed inside a capped reply still transitions.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ApplySnapshotUpdate_WhenFetchTruncated_StillAppliesPresentAlarms()
|
||||||
|
{
|
||||||
|
Guid presentGuid = new Guid("22222222-2222-2222-2222-222222222222");
|
||||||
|
Guid missingGuid = new Guid("11111111-1111-1111-1111-111111111111");
|
||||||
|
|
||||||
|
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot = new()
|
||||||
|
{
|
||||||
|
[presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.UnackAlm),
|
||||||
|
[missingGuid] = NewRecord(missingGuid, MxAlarmStateKind.UnackAlm),
|
||||||
|
};
|
||||||
|
Dictionary<Guid, MxAlarmSnapshotRecord> next = new()
|
||||||
|
{
|
||||||
|
[presentGuid] = NewRecord(presentGuid, MxAlarmStateKind.AckAlm),
|
||||||
|
};
|
||||||
|
|
||||||
|
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
||||||
|
WnWrapAlarmConsumer.ComputeTransitions(snapshot, next);
|
||||||
|
|
||||||
|
MxAlarmTransitionEvent single = Assert.Single(transitions);
|
||||||
|
Assert.Equal(presentGuid, single.Record.AlarmGuid);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackAlm, single.PreviousState);
|
||||||
|
Assert.Equal(MxAlarmStateKind.AckAlm, single.Record.State);
|
||||||
|
|
||||||
|
WnWrapAlarmConsumer.ApplySnapshotUpdate(snapshot, next, truncated: true);
|
||||||
|
|
||||||
|
Assert.Equal(MxAlarmStateKind.AckAlm, snapshot[presentGuid].State);
|
||||||
|
Assert.True(snapshot.ContainsKey(missingGuid));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Truncation is detected from the raw ALARM-element count, not the
|
||||||
|
/// parsed dictionary size. A record dropped for a malformed GUID still
|
||||||
|
/// consumed a slot in the capped reply, so counting only survivors
|
||||||
|
/// would let a truncated fetch pass as complete and re-open the cliff.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ParseSnapshotXml_CountsRawAlarmElements_IncludingRecordsDroppedForBadGuid()
|
||||||
|
{
|
||||||
|
const int Cap = 64;
|
||||||
|
string xml = BuildAlarmXml(Cap).Replace(
|
||||||
|
"<GUID>00000000000000000000000000000001</GUID>",
|
||||||
|
"<GUID>not-a-guid</GUID>");
|
||||||
|
|
||||||
|
Dictionary<Guid, MxAlarmSnapshotRecord> records =
|
||||||
|
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
|
||||||
|
|
||||||
|
Assert.Equal(Cap, fetchedRecordCount);
|
||||||
|
Assert.Equal(Cap - 1, records.Count);
|
||||||
|
Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The per-fetch cap comes from the launcher-set environment variable;
|
||||||
|
/// a missing, unparseable, or below-floor value must fall back to the
|
||||||
|
/// 1024 default rather than throw. A bad environment value must never
|
||||||
|
/// stop the alarm consumer from starting.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="environmentValue">Raw environment value under test.</param>
|
||||||
|
/// <param name="expected">Expected resolved cap.</param>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, 1024)]
|
||||||
|
[InlineData("", 1024)]
|
||||||
|
[InlineData("not-a-number", 1024)]
|
||||||
|
[InlineData("0", 1024)]
|
||||||
|
[InlineData("-5", 1024)]
|
||||||
|
[InlineData("63", 1024)]
|
||||||
|
[InlineData("64", 64)]
|
||||||
|
[InlineData("4096", 4096)]
|
||||||
|
public void ResolveMaxAlarmsPerFetch_WithEnvironmentValue_FallsBackToDefaultWhenUnusable(
|
||||||
|
string? environmentValue,
|
||||||
|
int expected)
|
||||||
|
{
|
||||||
|
string? original = Environment.GetEnvironmentVariable(
|
||||||
|
WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(
|
||||||
|
WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName,
|
||||||
|
environmentValue);
|
||||||
|
|
||||||
|
Assert.Equal(expected, WnWrapAlarmConsumer.ResolveMaxAlarmsPerFetch());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable(
|
||||||
|
WnWrapAlarmConsumer.MaxAlarmsPerFetchEnvironmentVariableName,
|
||||||
|
original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a well-formed ALARM_RECORDS payload with
|
||||||
|
/// <paramref name="count"/> distinct alarms. GUIDs are the dashless
|
||||||
|
/// 32-char hex form wnwrap actually emits, numbered from 1 so a test
|
||||||
|
/// can name one (e.g. ...0001) to corrupt.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="count">Number of ALARM elements to emit.</param>
|
||||||
|
/// <returns>The XML payload.</returns>
|
||||||
|
private static string BuildAlarmXml(int count)
|
||||||
|
{
|
||||||
|
System.Text.StringBuilder sb = new System.Text.StringBuilder();
|
||||||
|
sb.Append("<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"")
|
||||||
|
.Append(count)
|
||||||
|
.Append("\">");
|
||||||
|
for (int index = 1; index <= count; index++)
|
||||||
|
{
|
||||||
|
sb.Append("<ALARM><GUID>")
|
||||||
|
.Append(index.ToString("X32", System.Globalization.CultureInfo.InvariantCulture))
|
||||||
|
.Append("</GUID>")
|
||||||
|
.Append("<DATE>2026/5/1</DATE><TIME>13:26:14.709</TIME>")
|
||||||
|
.Append("<GMTOFFSET>240</GMTOFFSET><DSTADJUST>0</DSTADJUST>")
|
||||||
|
.Append("<PROVIDER_NODE>TEST-NODE</PROVIDER_NODE>")
|
||||||
|
.Append("<PROVIDER_NAME>Galaxy</PROVIDER_NAME>")
|
||||||
|
.Append("<GROUP>TestArea</GROUP>")
|
||||||
|
.Append("<TAGNAME>TestMachine_")
|
||||||
|
.Append(index.ToString(System.Globalization.CultureInfo.InvariantCulture))
|
||||||
|
.Append(".TestAlarm</TAGNAME>")
|
||||||
|
.Append("<TYPE>DSC</TYPE><VALUE>true</VALUE><LIMIT>true</LIMIT>")
|
||||||
|
.Append("<PRIORITY>500</PRIORITY><STATE>UNACK_ALM</STATE>")
|
||||||
|
.Append("<OPERATOR_NODE></OPERATOR_NODE><OPERATOR_NAME></OPERATOR_NAME>")
|
||||||
|
.Append("<ALARM_COMMENT>Test alarm</ALARM_COMMENT></ALARM>");
|
||||||
|
}
|
||||||
|
sb.Append("</ALARM_RECORDS>");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Single-pass parse parity (Task 23). The per-field SelectSingleNode calls
|
||||||
|
// were replaced by one walk over ChildNodes; these pin the semantics that
|
||||||
|
// walk has to reproduce.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An absent child element and a present-but-empty one must both yield
|
||||||
|
/// <see cref="string.Empty"/>, exactly as
|
||||||
|
/// <c>SelectSingleNode(name)?.InnerText ?? string.Empty</c> did.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ParseSnapshotXml_WithAbsentAndEmptyChildren_YieldsEmptyStrings()
|
||||||
|
{
|
||||||
|
string xml =
|
||||||
|
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
|
||||||
|
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
|
||||||
|
"<TAGNAME></TAGNAME>" +
|
||||||
|
"<STATE>UNACK_ALM</STATE></ALARM>" +
|
||||||
|
"</ALARM_RECORDS>";
|
||||||
|
|
||||||
|
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
|
||||||
|
MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, record.TagName); // present but empty
|
||||||
|
Assert.Equal(string.Empty, record.ProviderNode); // absent
|
||||||
|
Assert.Equal(string.Empty, record.Value); // absent
|
||||||
|
Assert.Equal(string.Empty, record.AlarmComment); // absent
|
||||||
|
Assert.Equal(0, record.Priority); // absent → ParseInt("") → 0
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Duplicate child elements must resolve to the FIRST occurrence —
|
||||||
|
/// <c>SelectSingleNode</c> returned the first match, so a last-wins
|
||||||
|
/// walk would silently change which value a malformed payload yields.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ParseSnapshotXml_WithDuplicateChildElements_TakesFirstOccurrence()
|
||||||
|
{
|
||||||
|
string xml =
|
||||||
|
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
|
||||||
|
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
|
||||||
|
"<TAGNAME>First</TAGNAME><TAGNAME>Second</TAGNAME>" +
|
||||||
|
"<PRIORITY>100</PRIORITY><PRIORITY>900</PRIORITY>" +
|
||||||
|
"<STATE>UNACK_ALM</STATE></ALARM>" +
|
||||||
|
"</ALARM_RECORDS>";
|
||||||
|
|
||||||
|
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
|
||||||
|
MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];
|
||||||
|
|
||||||
|
Assert.Equal("First", record.TagName);
|
||||||
|
Assert.Equal(100, record.Priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Field names are matched ordinally and case-sensitively, as the
|
||||||
|
/// XPath node test was. A lowercase element must not populate the
|
||||||
|
/// field its uppercase counterpart owns.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ParseSnapshotXml_WithWrongCaseChildElement_DoesNotPopulateField()
|
||||||
|
{
|
||||||
|
string xml =
|
||||||
|
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">" +
|
||||||
|
"<ALARM><GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>" +
|
||||||
|
"<tagname>ShouldBeIgnored</tagname>" +
|
||||||
|
"<STATE>UNACK_ALM</STATE></ALARM>" +
|
||||||
|
"</ALARM_RECORDS>";
|
||||||
|
|
||||||
|
Dictionary<Guid, MxAlarmSnapshotRecord> records = WnWrapAlarmConsumer.ParseSnapshotXml(xml);
|
||||||
|
MxAlarmSnapshotRecord record = records[new Guid("BCC47053-9542-4D65-BDAA-BCDEA6A32A73")];
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, record.TagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-element children (whitespace text, comments, CDATA) must be
|
||||||
|
/// skipped rather than matched by name, and a nested element must
|
||||||
|
/// contribute its InnerText exactly as <c>InnerText</c> always did.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void ParseSnapshotXml_WithCommentsAndWhitespace_IgnoresNonElementChildren()
|
||||||
|
{
|
||||||
|
string xml =
|
||||||
|
"<?xml version=\"1.0\"?><ALARM_RECORDS COUNT=\"1\">\n" +
|
||||||
|
" <ALARM>\n" +
|
||||||
|
" <!-- a comment -->\n" +
|
||||||
|
" <GUID>BCC4705395424D65BDAABCDEA6A32A73</GUID>\n" +
|
||||||
|
" <TAGNAME>TestMachine.TestAlarm</TAGNAME>\n" +
|
||||||
|
" <STATE>UNACK_ALM</STATE>\n" +
|
||||||
|
" </ALARM>\n" +
|
||||||
|
"</ALARM_RECORDS>";
|
||||||
|
|
||||||
|
Dictionary<Guid, MxAlarmSnapshotRecord> records =
|
||||||
|
WnWrapAlarmConsumer.ParseSnapshotXml(xml, out int fetchedRecordCount);
|
||||||
|
|
||||||
|
Assert.Equal(1, fetchedRecordCount);
|
||||||
|
MxAlarmSnapshotRecord record = Assert.Single(records).Value;
|
||||||
|
Assert.Equal("TestMachine.TestAlarm", record.TagName);
|
||||||
|
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
|
||||||
|
}
|
||||||
|
|
||||||
private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state)
|
private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state)
|
||||||
{
|
{
|
||||||
return new MxAlarmSnapshotRecord
|
return new MxAlarmSnapshotRecord
|
||||||
|
|||||||
@@ -19,7 +19,26 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
|||||||
internal const string WriteCompletionWaitEnvironmentVariableName =
|
internal const string WriteCompletionWaitEnvironmentVariableName =
|
||||||
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
|
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
|
||||||
|
|
||||||
private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500);
|
/// <summary>
|
||||||
|
/// Environment variable the gateway's WorkerProcessLauncher sets from
|
||||||
|
/// MxGateway:Alarms:PollIntervalMilliseconds. A missing or invalid
|
||||||
|
/// value falls back to <see cref="DefaultAlarmPollInterval"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal const string AlarmPollIntervalEnvironmentVariableName =
|
||||||
|
"MXGATEWAY_ALARM_POLL_INTERVAL_MS";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Floor on the resolved alarm poll cadence. Mirrors the gateway-side
|
||||||
|
/// MxGateway:Alarms:PollIntervalMilliseconds minimum so a value that
|
||||||
|
/// slipped past validation (or an environment set by hand) still can
|
||||||
|
/// not starve the STA's command path.
|
||||||
|
/// </summary>
|
||||||
|
internal static readonly TimeSpan MinimumAlarmPollInterval = TimeSpan.FromMilliseconds(100);
|
||||||
|
|
||||||
|
/// <summary>Default alarm poll cadence when the environment says nothing usable.</summary>
|
||||||
|
internal static readonly TimeSpan DefaultAlarmPollInterval = TimeSpan.FromMilliseconds(500);
|
||||||
|
|
||||||
|
private readonly TimeSpan alarmPollInterval = ResolveAlarmPollInterval();
|
||||||
|
|
||||||
private readonly IMxAccessComObjectFactory factory;
|
private readonly IMxAccessComObjectFactory factory;
|
||||||
private readonly IMxAccessEventSink eventSink;
|
private readonly IMxAccessEventSink eventSink;
|
||||||
@@ -191,6 +210,31 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
|||||||
: MxAccessCommandExecutor.DefaultWriteCompletionTimeout;
|
: MxAccessCommandExecutor.DefaultWriteCompletionTimeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the alarm poll cadence from the launcher-provided
|
||||||
|
/// environment variable. A missing, unparseable, or out-of-range value
|
||||||
|
/// falls back to <see cref="DefaultAlarmPollInterval"/> rather than
|
||||||
|
/// faulting the worker: an alarm poll that runs at the default cadence
|
||||||
|
/// is always safe, and a session that refuses to start over a
|
||||||
|
/// mistyped environment variable is not.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The cadence the alarm poll loop waits between polls.</returns>
|
||||||
|
internal static TimeSpan ResolveAlarmPollInterval()
|
||||||
|
{
|
||||||
|
string? value = Environment.GetEnvironmentVariable(AlarmPollIntervalEnvironmentVariableName);
|
||||||
|
if (!int.TryParse(
|
||||||
|
value,
|
||||||
|
System.Globalization.NumberStyles.Integer,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
out int milliseconds))
|
||||||
|
{
|
||||||
|
return DefaultAlarmPollInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan resolved = TimeSpan.FromMilliseconds(milliseconds);
|
||||||
|
return resolved < MinimumAlarmPollInterval ? DefaultAlarmPollInterval : resolved;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts the MXAccess COM session asynchronously.
|
/// Starts the MXAccess COM session asynchronously.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -274,7 +318,7 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Task.Delay(AlarmPollInterval, cancellationToken).ConfigureAwait(false);
|
await Task.Delay(alarmPollInterval, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Xml;
|
using System.Xml;
|
||||||
@@ -49,11 +50,39 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
private const string DefaultVersion = "1.0";
|
private const string DefaultVersion = "1.0";
|
||||||
private const int DefaultMaxAlarmsPerFetch = 1024;
|
private const int DefaultMaxAlarmsPerFetch = 1024;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Floor on the resolved per-fetch cap. Mirrors the gateway-side
|
||||||
|
/// <c>MxGateway:Alarms:MaxAlarmsPerFetch</c> minimum so a value that
|
||||||
|
/// slipped past startup validation still leaves a usable snapshot
|
||||||
|
/// window.
|
||||||
|
/// </summary>
|
||||||
|
internal const int MinimumMaxAlarmsPerFetch = 64;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Environment variable the gateway's <c>WorkerProcessLauncher</c>
|
||||||
|
/// sets from <c>MxGateway:Alarms:MaxAlarmsPerFetch</c>. A missing,
|
||||||
|
/// unparseable, or below-floor value falls back to
|
||||||
|
/// <see cref="DefaultMaxAlarmsPerFetch"/> — a bad environment value
|
||||||
|
/// must never keep the alarm consumer from starting.
|
||||||
|
/// </summary>
|
||||||
|
internal const string MaxAlarmsPerFetchEnvironmentVariableName =
|
||||||
|
"MXGATEWAY_ALARM_MAX_ALARMS_PER_FETCH";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum gap between truncated-fetch warnings. A galaxy parked above
|
||||||
|
/// the cap truncates on *every* poll, so an unthrottled warning would
|
||||||
|
/// be two log lines a second forever.
|
||||||
|
/// </summary>
|
||||||
|
private const long TruncationWarningIntervalMilliseconds = 60_000;
|
||||||
|
|
||||||
private readonly object syncRoot = new object();
|
private readonly object syncRoot = new object();
|
||||||
private readonly Dictionary<Guid, MxAlarmSnapshotRecord> latestSnapshot =
|
private readonly Dictionary<Guid, MxAlarmSnapshotRecord> latestSnapshot =
|
||||||
new Dictionary<Guid, MxAlarmSnapshotRecord>();
|
new Dictionary<Guid, MxAlarmSnapshotRecord>();
|
||||||
private readonly int maxAlarmsPerFetch;
|
private readonly int maxAlarmsPerFetch;
|
||||||
|
private readonly Stopwatch truncationWarningClock = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
private long lastTruncationWarningMilliseconds = -TruncationWarningIntervalMilliseconds;
|
||||||
|
private long truncatedFetchCount;
|
||||||
private wwAlarmConsumerClass? client;
|
private wwAlarmConsumerClass? client;
|
||||||
private wwAlarmConsumerClass? ackClient;
|
private wwAlarmConsumerClass? ackClient;
|
||||||
private bool subscribed;
|
private bool subscribed;
|
||||||
@@ -67,7 +96,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
/// every COM call stays on the STA that owns the apartment.
|
/// every COM call stays on the STA that owns the apartment.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WnWrapAlarmConsumer()
|
public WnWrapAlarmConsumer()
|
||||||
: this(new wwAlarmConsumerClass(), DefaultMaxAlarmsPerFetch)
|
: this(new wwAlarmConsumerClass(), ResolveMaxAlarmsPerFetch())
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +115,27 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
: DefaultMaxAlarmsPerFetch;
|
: DefaultMaxAlarmsPerFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the per-fetch cap from the launcher-provided environment
|
||||||
|
/// variable. A missing, unparseable, or below-floor value falls back
|
||||||
|
/// to <see cref="DefaultMaxAlarmsPerFetch"/> rather than throwing:
|
||||||
|
/// polling at the default cap is always safe, and a session that
|
||||||
|
/// refuses to subscribe over a mistyped environment variable is not.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The cap passed to <c>GetXmlCurrentAlarms2</c>.</returns>
|
||||||
|
internal static int ResolveMaxAlarmsPerFetch()
|
||||||
|
{
|
||||||
|
string? value = Environment.GetEnvironmentVariable(MaxAlarmsPerFetchEnvironmentVariableName);
|
||||||
|
return int.TryParse(
|
||||||
|
value,
|
||||||
|
NumberStyles.Integer,
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
out int cap)
|
||||||
|
&& cap >= MinimumMaxAlarmsPerFetch
|
||||||
|
? cap
|
||||||
|
: DefaultMaxAlarmsPerFetch;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fires once per detected alarm-state transition (raise, acknowledge,
|
/// Fires once per detected alarm-state transition (raise, acknowledge,
|
||||||
/// clear, or new-alarm-already-acked-on-arrival), dispatched from
|
/// clear, or new-alarm-already-acked-on-arrival), dispatched from
|
||||||
@@ -282,6 +332,15 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// This is the set the gateway's reconcile pass diffs its cache
|
||||||
|
/// against, so an alarm missing here is what the gateway reads as a
|
||||||
|
/// Clear. After a truncated fetch the snapshot deliberately still
|
||||||
|
/// carries alarms the capped reply could not mention — see
|
||||||
|
/// <see cref="PollOnce"/>. Erring toward "still active" keeps the feed
|
||||||
|
/// at-least-once: a stale entry is repaired by the next sub-cap poll,
|
||||||
|
/// whereas a dropped one is broadcast as a Clear that never happened.
|
||||||
|
/// </remarks>
|
||||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
|
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
|
||||||
{
|
{
|
||||||
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||||
@@ -300,6 +359,16 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sink for the rate-limited truncated-fetch warning. Defaults to
|
||||||
|
/// <see cref="Console.Error"/>, the stream
|
||||||
|
/// <c>WorkerConsoleLogger</c> already writes to, so the line lands in
|
||||||
|
/// the worker's captured stderr alongside the rest of its log. Tests
|
||||||
|
/// substitute a collector. The message carries identifiers and counts
|
||||||
|
/// only — never alarm values, tag names, or comments.
|
||||||
|
/// </summary>
|
||||||
|
internal Action<string>? TruncationWarningSink { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void PollOnce()
|
public void PollOnce()
|
||||||
{
|
{
|
||||||
@@ -316,17 +385,28 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
string xml = xmlObj?.ToString() ?? string.Empty;
|
string xml = xmlObj?.ToString() ?? string.Empty;
|
||||||
if (xml.Length == 0) return;
|
if (xml.Length == 0) return;
|
||||||
|
|
||||||
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml);
|
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml, out int fetchedRecordCount);
|
||||||
|
|
||||||
|
// TRUNCATION CLIFF. GetXmlCurrentAlarms2 caps its reply at maxAlmCnt
|
||||||
|
// and gives no "there is more" flag, so a reply holding exactly the
|
||||||
|
// cap is indistinguishable from a galaxy that happens to have exactly
|
||||||
|
// that many active alarms. Treat the ambiguous case as truncated: the
|
||||||
|
// false-positive cost is a snapshot that stays stale for one poll, the
|
||||||
|
// false-negative cost is every alarm past the cap reading as cleared.
|
||||||
|
bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch);
|
||||||
|
|
||||||
IReadOnlyList<MxAlarmTransitionEvent> transitions;
|
IReadOnlyList<MxAlarmTransitionEvent> transitions;
|
||||||
|
int retainedCount;
|
||||||
lock (syncRoot)
|
lock (syncRoot)
|
||||||
{
|
{
|
||||||
transitions = ComputeTransitions(latestSnapshot, next);
|
transitions = ComputeTransitions(latestSnapshot, next);
|
||||||
latestSnapshot.Clear();
|
ApplySnapshotUpdate(latestSnapshot, next, truncated);
|
||||||
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
|
retainedCount = latestSnapshot.Count;
|
||||||
{
|
}
|
||||||
latestSnapshot[kv.Key] = kv.Value;
|
|
||||||
}
|
if (truncated)
|
||||||
|
{
|
||||||
|
WarnTruncatedFetch(fetchedRecordCount, next.Count, retainedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transitions.Count == 0) return;
|
if (transitions.Count == 0) return;
|
||||||
@@ -338,6 +418,111 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides whether a fetch that came back holding
|
||||||
|
/// <paramref name="fetchedRecordCount"/> records hit the cap.
|
||||||
|
/// <c>GetXmlCurrentAlarms2</c> caps its reply at <c>maxAlmCnt</c> and
|
||||||
|
/// offers no "more available" flag, so a reply at exactly the cap is
|
||||||
|
/// indistinguishable from a galaxy that happens to hold exactly that
|
||||||
|
/// many active alarms; both are treated as truncated. Exposed as
|
||||||
|
/// <c>internal static</c> so the rule is unit-testable without the
|
||||||
|
/// wnwrapConsumer COM object.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fetchedRecordCount">ALARM records the reply carried.</param>
|
||||||
|
/// <param name="maxAlarmsPerFetch">The cap that was passed to the fetch.</param>
|
||||||
|
/// <returns><see langword="true"/> when the reply must be treated as truncated.</returns>
|
||||||
|
internal static bool IsTruncatedFetch(int fetchedRecordCount, int maxAlarmsPerFetch)
|
||||||
|
{
|
||||||
|
return fetchedRecordCount >= maxAlarmsPerFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Folds a freshly fetched snapshot into the retained one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A complete fetch is authoritative about <em>absence</em>:
|
||||||
|
/// replacing the snapshot wholesale is exactly what lets a cleared
|
||||||
|
/// alarm drop out of <see cref="SnapshotActiveAlarms"/>, which is
|
||||||
|
/// the evidence the gateway's reconcile pass turns into a Clear.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A truncated fetch is authoritative about <em>presence</em>
|
||||||
|
/// only. Merging rather than replacing means alarms the capped
|
||||||
|
/// reply did carry update normally, while alarms it had no room to
|
||||||
|
/// mention are retained untouched — so the gateway infers no Clear
|
||||||
|
/// from their absence. The cost is bounded staleness: a genuinely
|
||||||
|
/// cleared alarm can linger in the snapshot until the first
|
||||||
|
/// sub-cap fetch evicts it. That direction is the safe one for an
|
||||||
|
/// at-least-once alarm feed — a late Clear is repaired by the next
|
||||||
|
/// complete poll, whereas a Clear that never happened is broadcast
|
||||||
|
/// to every subscriber and cannot be taken back.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="snapshot">The retained snapshot, updated in place.</param>
|
||||||
|
/// <param name="next">The snapshot just parsed from the fetch.</param>
|
||||||
|
/// <param name="truncated">Whether the fetch hit the per-fetch cap.</param>
|
||||||
|
internal static void ApplySnapshotUpdate(
|
||||||
|
Dictionary<Guid, MxAlarmSnapshotRecord> snapshot,
|
||||||
|
Dictionary<Guid, MxAlarmSnapshotRecord> next,
|
||||||
|
bool truncated)
|
||||||
|
{
|
||||||
|
if (snapshot is null) throw new ArgumentNullException(nameof(snapshot));
|
||||||
|
if (next is null) throw new ArgumentNullException(nameof(next));
|
||||||
|
|
||||||
|
if (!truncated)
|
||||||
|
{
|
||||||
|
snapshot.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
|
||||||
|
{
|
||||||
|
snapshot[kv.Key] = kv.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits the truncated-fetch warning at most once per
|
||||||
|
/// <see cref="TruncationWarningIntervalMilliseconds"/>, counting every
|
||||||
|
/// truncated poll in between so the operator can tell a one-off burst
|
||||||
|
/// from a galaxy permanently parked above the cap.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fetchedRecordCount">ALARM records the capped reply carried.</param>
|
||||||
|
/// <param name="parsedRecordCount">Records that survived GUID parsing.</param>
|
||||||
|
/// <param name="retainedCount">Snapshot size after the merge.</param>
|
||||||
|
private void WarnTruncatedFetch(int fetchedRecordCount, int parsedRecordCount, int retainedCount)
|
||||||
|
{
|
||||||
|
long total;
|
||||||
|
long elapsed;
|
||||||
|
lock (syncRoot)
|
||||||
|
{
|
||||||
|
total = ++truncatedFetchCount;
|
||||||
|
elapsed = truncationWarningClock.ElapsedMilliseconds;
|
||||||
|
if (elapsed - lastTruncationWarningMilliseconds < TruncationWarningIntervalMilliseconds)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTruncationWarningMilliseconds = elapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifiers and counts only — no tag names, values, limits, or
|
||||||
|
// comments, per the gateway's "don't log tag values by default" rule.
|
||||||
|
string message = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"level=Warning event=AlarmSnapshotTruncated maxAlarmsPerFetch={0} fetchedRecords={1} "
|
||||||
|
+ "parsedRecords={2} retainedSnapshotSize={3} truncatedFetchesSinceStart={4} "
|
||||||
|
+ "note=absence-implies-clear suppressed for this poll; raise MxGateway:Alarms:MaxAlarmsPerFetch",
|
||||||
|
maxAlarmsPerFetch,
|
||||||
|
fetchedRecordCount,
|
||||||
|
parsedRecordCount,
|
||||||
|
retainedCount,
|
||||||
|
total);
|
||||||
|
|
||||||
|
Action<string> sink = TruncationWarningSink ?? Console.Error.WriteLine;
|
||||||
|
sink(message);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pure snapshot-to-transitions diff. Compares the previous polled
|
/// Pure snapshot-to-transitions diff. Compares the previous polled
|
||||||
/// snapshot to the next snapshot and produces one
|
/// snapshot to the next snapshot and produces one
|
||||||
@@ -355,6 +540,21 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
/// <item><description>A GUID present in both with a different <see cref="MxAlarmSnapshotRecord.State"/> produces a transition carrying the prior state.</description></item>
|
/// <item><description>A GUID present in both with a different <see cref="MxAlarmSnapshotRecord.State"/> produces a transition carrying the prior state.</description></item>
|
||||||
/// <item><description>A GUID present in <paramref name="previous"/> but absent from <paramref name="next"/> produces no transition. AVEVA drops cleared alarms from the active set; the snapshot simply stops mentioning them.</description></item>
|
/// <item><description>A GUID present in <paramref name="previous"/> but absent from <paramref name="next"/> produces no transition. AVEVA drops cleared alarms from the active set; the snapshot simply stops mentioning them.</description></item>
|
||||||
/// </list>
|
/// </list>
|
||||||
|
/// <para>
|
||||||
|
/// The absence rule is deliberately inert here: this diff never
|
||||||
|
/// turns a disappearance into a Clear. The clear is inferred one
|
||||||
|
/// level up, by the gateway's reconcile pass diffing its cache
|
||||||
|
/// against <see cref="SnapshotActiveAlarms"/>, which reads the
|
||||||
|
/// snapshot <see cref="PollOnce"/> maintains from these same
|
||||||
|
/// dictionaries. That is why the truncation guard lives in
|
||||||
|
/// <see cref="PollOnce"/>'s snapshot update rather than in this
|
||||||
|
/// method: suppressing "absence implies Clear" means declining to
|
||||||
|
/// *evict* alarms a capped fetch could not mention, not declining
|
||||||
|
/// to emit a transition this method was never going to emit.
|
||||||
|
/// Passing a truncated <paramref name="next"/> straight through
|
||||||
|
/// stays correct for the alarms it does carry — first sightings
|
||||||
|
/// and state changes are computed from presence alone.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="previous">The snapshot from the previous poll (or empty on first call).</param>
|
/// <param name="previous">The snapshot from the previous poll (or empty on first call).</param>
|
||||||
/// <param name="next">The snapshot just parsed from <c>GetXmlCurrentAlarms2</c>.</param>
|
/// <param name="next">The snapshot just parsed from <c>GetXmlCurrentAlarms2</c>.</param>
|
||||||
@@ -393,9 +593,29 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
/// <param name="xml">The XML snapshot payload.</param>
|
/// <param name="xml">The XML snapshot payload.</param>
|
||||||
/// <returns>The parsed alarm snapshot records, keyed by alarm GUID.</returns>
|
/// <returns>The parsed alarm snapshot records, keyed by alarm GUID.</returns>
|
||||||
public static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(string xml)
|
public static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(string xml)
|
||||||
|
{
|
||||||
|
return ParseSnapshotXml(xml, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="ParseSnapshotXml(string)"/> plus the raw
|
||||||
|
/// <c>ALARM</c>-element count, which <see cref="PollOnce"/> compares
|
||||||
|
/// against the per-fetch cap to detect a truncated reply. The raw
|
||||||
|
/// count — not the dictionary size — is the right signal: records
|
||||||
|
/// dropped for a malformed GUID still consumed a slot in the capped
|
||||||
|
/// reply, so counting only the survivors would under-report and let a
|
||||||
|
/// truncated fetch pass as complete.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="xml">The XML snapshot payload.</param>
|
||||||
|
/// <param name="alarmRecordCount">Number of <c>ALARM</c> elements in the payload.</param>
|
||||||
|
/// <returns>The parsed alarm snapshot records, keyed by alarm GUID.</returns>
|
||||||
|
internal static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(
|
||||||
|
string xml,
|
||||||
|
out int alarmRecordCount)
|
||||||
{
|
{
|
||||||
Dictionary<Guid, MxAlarmSnapshotRecord> records =
|
Dictionary<Guid, MxAlarmSnapshotRecord> records =
|
||||||
new Dictionary<Guid, MxAlarmSnapshotRecord>();
|
new Dictionary<Guid, MxAlarmSnapshotRecord>();
|
||||||
|
alarmRecordCount = 0;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(xml)) return records;
|
if (string.IsNullOrWhiteSpace(xml)) return records;
|
||||||
|
|
||||||
@@ -404,45 +624,96 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
|||||||
XmlNodeList? alarmNodes = doc.SelectNodes("/ALARM_RECORDS/ALARM");
|
XmlNodeList? alarmNodes = doc.SelectNodes("/ALARM_RECORDS/ALARM");
|
||||||
if (alarmNodes is null) return records;
|
if (alarmNodes is null) return records;
|
||||||
|
|
||||||
|
alarmRecordCount = alarmNodes.Count;
|
||||||
|
|
||||||
foreach (XmlNode alarmNode in alarmNodes)
|
foreach (XmlNode alarmNode in alarmNodes)
|
||||||
{
|
{
|
||||||
string guidHex = TextOf(alarmNode, "GUID");
|
// One pass over the child elements instead of 17 SelectSingleNode
|
||||||
|
// XPath evaluations per record. At the 1024-record cap that is the
|
||||||
|
// difference between ~17k XPath expression compilations plus child
|
||||||
|
// walks and one walk per record, on the STA that also serves reads
|
||||||
|
// and writes.
|
||||||
|
//
|
||||||
|
// Parity with the XPath it replaces: SelectSingleNode(name) picks
|
||||||
|
// the FIRST child ELEMENT of that name (ordinal, case-sensitive)
|
||||||
|
// and yields its InnerText; an absent child yields string.Empty,
|
||||||
|
// and so does a present-but-empty one. Hence: element nodes only,
|
||||||
|
// first-match-wins (null means "not seen yet"), and a single
|
||||||
|
// coalesce to string.Empty at the end — which keeps the absent and
|
||||||
|
// empty cases indistinguishable exactly as before.
|
||||||
|
string? guidHex = null;
|
||||||
|
string? xmlDate = null;
|
||||||
|
string? xmlTime = null;
|
||||||
|
string? gmtOffset = null;
|
||||||
|
string? dstAdjust = null;
|
||||||
|
string? providerNode = null;
|
||||||
|
string? providerName = null;
|
||||||
|
string? group = null;
|
||||||
|
string? tagName = null;
|
||||||
|
string? type = null;
|
||||||
|
string? value = null;
|
||||||
|
string? limit = null;
|
||||||
|
string? priority = null;
|
||||||
|
string? state = null;
|
||||||
|
string? operatorNode = null;
|
||||||
|
string? operatorName = null;
|
||||||
|
string? alarmComment = null;
|
||||||
|
|
||||||
|
foreach (XmlNode child in alarmNode.ChildNodes)
|
||||||
|
{
|
||||||
|
if (child.NodeType != XmlNodeType.Element) continue;
|
||||||
|
|
||||||
|
switch (child.Name)
|
||||||
|
{
|
||||||
|
case "GUID": guidHex ??= child.InnerText; break;
|
||||||
|
case "DATE": xmlDate ??= child.InnerText; break;
|
||||||
|
case "TIME": xmlTime ??= child.InnerText; break;
|
||||||
|
case "GMTOFFSET": gmtOffset ??= child.InnerText; break;
|
||||||
|
case "DSTADJUST": dstAdjust ??= child.InnerText; break;
|
||||||
|
case "PROVIDER_NODE": providerNode ??= child.InnerText; break;
|
||||||
|
case "PROVIDER_NAME": providerName ??= child.InnerText; break;
|
||||||
|
case "GROUP": group ??= child.InnerText; break;
|
||||||
|
case "TAGNAME": tagName ??= child.InnerText; break;
|
||||||
|
case "TYPE": type ??= child.InnerText; break;
|
||||||
|
case "VALUE": value ??= child.InnerText; break;
|
||||||
|
case "LIMIT": limit ??= child.InnerText; break;
|
||||||
|
case "PRIORITY": priority ??= child.InnerText; break;
|
||||||
|
case "STATE": state ??= child.InnerText; break;
|
||||||
|
case "OPERATOR_NODE": operatorNode ??= child.InnerText; break;
|
||||||
|
case "OPERATOR_NAME": operatorName ??= child.InnerText; break;
|
||||||
|
case "ALARM_COMMENT": alarmComment ??= child.InnerText; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!TryParseHexGuid(guidHex, out Guid guid)) continue;
|
if (!TryParseHexGuid(guidHex, out Guid guid)) continue;
|
||||||
|
|
||||||
string xmlDate = TextOf(alarmNode, "DATE");
|
|
||||||
string xmlTime = TextOf(alarmNode, "TIME");
|
|
||||||
int gmtOffset = ParseInt(TextOf(alarmNode, "GMTOFFSET"));
|
|
||||||
int dstAdjust = ParseInt(TextOf(alarmNode, "DSTADJUST"));
|
|
||||||
DateTime tsUtc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
|
DateTime tsUtc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
|
||||||
xmlDate, xmlTime, gmtOffset, dstAdjust);
|
xmlDate ?? string.Empty,
|
||||||
|
xmlTime ?? string.Empty,
|
||||||
|
ParseInt(gmtOffset ?? string.Empty),
|
||||||
|
ParseInt(dstAdjust ?? string.Empty));
|
||||||
|
|
||||||
records[guid] = new MxAlarmSnapshotRecord
|
records[guid] = new MxAlarmSnapshotRecord
|
||||||
{
|
{
|
||||||
AlarmGuid = guid,
|
AlarmGuid = guid,
|
||||||
TransitionTimestampUtc = tsUtc,
|
TransitionTimestampUtc = tsUtc,
|
||||||
ProviderNode = TextOf(alarmNode, "PROVIDER_NODE"),
|
ProviderNode = providerNode ?? string.Empty,
|
||||||
ProviderName = TextOf(alarmNode, "PROVIDER_NAME"),
|
ProviderName = providerName ?? string.Empty,
|
||||||
Group = TextOf(alarmNode, "GROUP"),
|
Group = group ?? string.Empty,
|
||||||
TagName = TextOf(alarmNode, "TAGNAME"),
|
TagName = tagName ?? string.Empty,
|
||||||
Type = TextOf(alarmNode, "TYPE"),
|
Type = type ?? string.Empty,
|
||||||
Value = TextOf(alarmNode, "VALUE"),
|
Value = value ?? string.Empty,
|
||||||
Limit = TextOf(alarmNode, "LIMIT"),
|
Limit = limit ?? string.Empty,
|
||||||
Priority = ParseInt(TextOf(alarmNode, "PRIORITY")),
|
Priority = ParseInt(priority ?? string.Empty),
|
||||||
State = AlarmRecordTransitionMapper.ParseStateKind(TextOf(alarmNode, "STATE")),
|
State = AlarmRecordTransitionMapper.ParseStateKind(state ?? string.Empty),
|
||||||
OperatorNode = TextOf(alarmNode, "OPERATOR_NODE"),
|
OperatorNode = operatorNode ?? string.Empty,
|
||||||
OperatorName = TextOf(alarmNode, "OPERATOR_NAME"),
|
OperatorName = operatorName ?? string.Empty,
|
||||||
AlarmComment = TextOf(alarmNode, "ALARM_COMMENT"),
|
AlarmComment = alarmComment ?? string.Empty,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return records;
|
return records;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string TextOf(XmlNode parent, string childName)
|
|
||||||
{
|
|
||||||
XmlNode? node = parent.SelectSingleNode(childName);
|
|
||||||
return node?.InnerText ?? string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int ParseInt(string text)
|
private static int ParseInt(string text)
|
||||||
{
|
{
|
||||||
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)
|
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)
|
||||||
|
|||||||
Reference in New Issue
Block a user