fix(galaxy): picker-form alarm ack waits for the feed snapshot instead of dialling bare tag.Attr (#904)
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
A picker-form native alarm (attributeRef tag.Attr) only learns the provider-qualified reference Galaxy!<Area>.tag.Attr from the alarm feed. After a host restart the condition can be restored from LocalDb before the feed's first snapshot reached the new driver, and an ack in that window dialled the bare tag.Attr, which the gateway's AcknowledgeAlarm refuses. The feed now exposes InitialSnapshotCompleted (set on snapshot_complete, after every snapshot entry has been raised). AcknowledgeAsync resolves the reference through ResolveAlarmAckReferenceAsync: when a picker-form tag has learned nothing yet and the feed is running, it waits for that snapshot (2 s, and the caller's token) and dials the reference the snapshot names, so the area always comes from the feed and a re-areaed object acks under its current area. No reference is guessed: if the snapshot does not name the alarm, or does not arrive in time, the authored reference is dialled as before and refused visibly. Chosen over the alternatives in the issue: a GR-browse area lookup can be stale against the runtime area and is not loaded for authored tags, and a gateway-side tag.Attr resolution needs a cross-repo deploy and would resolve from the same monitor cache the snapshot already carries. Tests: 5 driver cases + 1 feed case (fail with the call site / sentinel reverted), and a skip-gated live gate GalaxyPickerAckBeforeSnapshotLiveTests, run against windev's TEST Galaxy (TestMachine_015.TestAlarm003): an ack issued before the snapshot dials Galaxy!TestArea.TestMachine_015.TestAlarm003 and is confirmed; with the call site reverted it fails with the gateway's InvalidRequest refusal. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -552,6 +552,73 @@ public sealed class GalaxyDriver
|
||||
internal string ToAlarmAckReference(string rawPath) =>
|
||||
_alarmFullRefByRawPath.TryGetValue(rawPath, out var full) ? full : ToAttributeRef(rawPath);
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on how long an acknowledge of a picker-form alarm waits for the alarm feed's first snapshot
|
||||
/// (lmxopcua#904). Kept well inside the host's AlarmAcknowledge capability timeout (5 s at the default tier),
|
||||
/// which also covers the credential lookup and the gateway RPC.
|
||||
/// </summary>
|
||||
internal TimeSpan AckSnapshotWait { get; set; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Test-visible: the wired alarm feed's first-snapshot signal (lmxopcua#904 live gate), null without a feed.</summary>
|
||||
internal Task? AlarmFeedInitialSnapshotForTests
|
||||
{
|
||||
get { lock (_alarmFeedLock) return _alarmFeed?.InitialSnapshotCompleted; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The reference an acknowledge of <paramref name="rawPath"/> dials (lmxopcua#904). A tag authored in the
|
||||
/// picker form (<c>tag.Attr</c>, no <c>Provider!</c>) only acquires the provider-qualified reference the
|
||||
/// gateway's <c>AcknowledgeAlarm</c> accepts from the alarm feed. Right after a host restart the condition
|
||||
/// can be restored from LocalDb before the feed's first snapshot reached this driver instance, so when no
|
||||
/// reference has been learned yet the ack waits — bounded by <see cref="AckSnapshotWait"/> and the caller's
|
||||
/// token — for that snapshot, which names every active or unacknowledged alarm in its current area. Nothing
|
||||
/// is guessed: the area comes only from the feed (so a re-areaed object acks under its current area), and
|
||||
/// when the feed still has not named the alarm the authored reference is dialled as before and the gateway
|
||||
/// refuses it visibly.
|
||||
/// </summary>
|
||||
/// <param name="rawPath">The condition's RawPath (its ConditionId).</param>
|
||||
/// <param name="cancellationToken">The acknowledge's cancellation token.</param>
|
||||
/// <returns>The gateway alarm reference to acknowledge.</returns>
|
||||
internal async Task<string> ResolveAlarmAckReferenceAsync(string rawPath, CancellationToken cancellationToken)
|
||||
{
|
||||
var reference = ToAlarmAckReference(rawPath);
|
||||
if (_alarmFullRefByRawPath.ContainsKey(rawPath)
|
||||
|| !_attributeRefByRawPath.ContainsKey(rawPath)
|
||||
|| reference.Contains('!'))
|
||||
{
|
||||
return reference;
|
||||
}
|
||||
|
||||
Task snapshot;
|
||||
lock (_alarmFeedLock)
|
||||
{
|
||||
// A feed that is not running delivers no snapshot — nothing to wait for.
|
||||
if (_alarmFeed is null || !_alarmFeedWired) return reference;
|
||||
snapshot = _alarmFeed.InitialSnapshotCompleted;
|
||||
}
|
||||
// Re-read: the snapshot may have landed since the first lookup.
|
||||
if (snapshot.IsCompleted) return ToAlarmAckReference(rawPath);
|
||||
|
||||
try
|
||||
{
|
||||
await snapshot.WaitAsync(AckSnapshotWait, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Fall through: dial what we have; the gateway refuses it and the host reverts the ack.
|
||||
}
|
||||
|
||||
var learned = ToAlarmAckReference(rawPath);
|
||||
if (learned == reference)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Galaxy driver {DriverInstanceId}: acknowledge of {RawPath} dials the area-free {AlarmRef} — the alarm feed has {State} named this alarm with its provider-qualified reference, so the gateway is expected to refuse it.",
|
||||
_driverInstanceId, rawPath, reference,
|
||||
snapshot.IsCompleted ? "not" : "not yet (its first snapshot did not arrive in time)");
|
||||
}
|
||||
return learned;
|
||||
}
|
||||
|
||||
/// <summary><c>Galaxy!Area.tag.Attr</c> → <c>tag.Attr</c>; null when the reference has no <c>!</c> provider
|
||||
/// prefix or nothing follows the area segment.</summary>
|
||||
private static string? StripProviderAndArea(string alarmFullReference)
|
||||
@@ -2740,7 +2807,8 @@ public sealed class GalaxyDriver
|
||||
var conditionReference = !string.IsNullOrEmpty(ack.ConditionId)
|
||||
? ack.ConditionId
|
||||
: ack.SourceNodeId;
|
||||
var alarmFullReference = ToAlarmAckReference(conditionReference);
|
||||
var alarmFullReference = await ResolveAlarmAckReferenceAsync(conditionReference, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// lmxopcua#749: the gateway acknowledges as a real Galaxy account (AuthenticateUser +
|
||||
// AckMsg write), and the OPC UA session has no Galaxy password. Resolve the account mapped
|
||||
|
||||
@@ -65,6 +65,9 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
|
||||
private readonly KeyValuePair<string, object?> _clientTag;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
// lmxopcua#904: completed by the first snapshot_complete sentinel — after every snapshot entry was raised.
|
||||
private readonly TaskCompletionSource _initialSnapshot = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private Task? _loop;
|
||||
private bool _disposed;
|
||||
|
||||
@@ -91,6 +94,9 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
|
||||
_clientTag = new KeyValuePair<string, object?>("galaxy.client", clientName ?? "<unknown>");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task InitialSnapshotCompleted => _initialSnapshot.Task;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Start()
|
||||
{
|
||||
@@ -159,6 +165,7 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
|
||||
break;
|
||||
case AlarmFeedMessage.PayloadOneofCase.SnapshotComplete:
|
||||
_logger.LogDebug("Galaxy alarm feed active-alarm snapshot complete.");
|
||||
_initialSnapshot.TrySetResult();
|
||||
break;
|
||||
default:
|
||||
// Empty oneof — worker / gateway version skew. Count and drop.
|
||||
|
||||
@@ -26,4 +26,14 @@ internal interface IGalaxyAlarmFeed : IAsyncDisposable
|
||||
/// calls are no-ops while the loop is running.
|
||||
/// </summary>
|
||||
void Start();
|
||||
|
||||
/// <summary>
|
||||
/// Completes once the feed has delivered its first full active-alarm snapshot (every snapshot entry has
|
||||
/// been raised on <see cref="OnAlarmTransition"/>, then the gateway's <c>snapshot_complete</c> sentinel
|
||||
/// arrived). Never faults; stays incomplete while the feed is not started or the gateway has not answered.
|
||||
/// lmxopcua#904: an acknowledge of a picker-form alarm waits on it (bounded) so the provider-qualified
|
||||
/// reference the snapshot names is known before the ack is dialled. The default is an already-completed
|
||||
/// task, for feeds with no snapshot phase (the test fakes raise synchronously).
|
||||
/// </summary>
|
||||
Task InitialSnapshotCompleted => Task.CompletedTask;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user