Merge #838 review fixes: per-condition deferral with liveness checks, lockout identity canonicalisation

This commit is contained in:
Joseph Doherty
2026-11-11 04:35:49 -05:00
8 changed files with 428 additions and 64 deletions
+7 -2
View File
@@ -208,7 +208,11 @@ Two things to know before relying on any of this:
notifier's `MonitoredNode2` drains a BOUNDED channel (4096, `FullMode = Wait`) and its delivery task takes `Lock` on a
verdict-cache miss (SDK `GetNodeMetadata` — flag off too — and #826's check); sync `ReportEvent` blocks on a full
channel ⇒ a >4096 burst under one `Lock` hold deadlocked the node manager (stalled at 4097). Condition events go
through `ReportEventWithoutBlocking` (async on overflow, ordered chain). Folder events follow Part 3's shape —
through `ReportEventWithoutBlocking`: our own copy of the SDK walk with a LIVENESS check before every hop (and in
`OnConditionReportEvent`) — a deferred event of a removed/replaced condition is dropped, never a ghost on `i=2253`;
per-CONDITION ordered queues on overflow; coalesce past 1024/condition or 65 536 total (oldest not-started gives way
to newest); Warning/Information + `otopcua.opcua.condition_events.{deferred,discarded}`. ConditionRefresh does not
flush the queues (documented residual). Folder events follow Part 3's shape —
`ReceiveEvents` on EventType + SourceNode (SDK check, kept first) AND our decision; non-condition events on a
folder item fail closed under enforcement. Tests: `FolderEventFloodWireTests`.
- **ns=0 standard nodes stay ungated by design, even with `Security:Acl:Enabled`** (#617) — `ServerStatus`,
@@ -252,7 +256,8 @@ Two things to know before relying on any of this:
for parallelism — ScadaBridge opens many keyed sessions from one IP), refused only on lockout or, uncounted, after
`SlotWaitSeconds`. An expired lockout resets the count. On
`None` endpoints the key is effectively IP-only (NAT neighbours share it, UserName only). ⚠️ **Two counts (#844):**
per (client, credential identity = `IOpcUaUserAuthenticator.DescribeCredential`, `key:<keyId>` / `user:<name>`),
per (client, CANONICAL credential identity = `IOpcUaUserAuthenticator.DescribeCredential`, `key:<keyId>` trimmed +
lower-cased, non-hex ids folded together, since SQL `=` is CI/trailing-space-blind / `user:<name>`),
cleared by a success with THAT identity, locks that identity; per client (`MaxFailedAttemptsPerClient`, default
2 × max) — **never reset by a success** (else a valid key launders guesses at another key id) — locks every
credential. ⚠️ **Only definite bad credentials count (#845):** store timeout / miss-budget / store throw / unset
+40 -8
View File
@@ -335,7 +335,11 @@ The host now sets `MaxFailedAuthenticationAttempts = 0` on every configuration i
- **Two counts per client (#844).** Each *definite* failure is charged to the (client, **credential identity**)
pair and to the client as a whole. The credential identity is `IOpcUaUserAuthenticator.DescribeCredential` —
for API keys `key:<keyId>` (the public lookup index, never the secret), for anything that is not a well-formed key
`user:<username>`.
`user:<username>`. It is **canonical**: the ConfigDb store matches the key id with SQL Server `=` (case-insensitive,
trailing spaces ignored), so the id is trimmed and lower-cased, and an id outside the minted lowercase-hex charset
(which no real key carries) folds into one shared `key:<not-a-minted-id>` identity; the username is trimmed and
lower-cased too. Otherwise `otua_abc…`, `otua_ABC…` and `otua_abc… ` would each get a fresh per-credential count for
one key.
- **Per credential:** at `MaxFailedAttempts` (5) failures of one identity, each within `FailureWindowSeconds` (60) of
the previous, that client is refused **that identity** `BadUserAccessDenied` for `LockoutSeconds` (300), without
the key store being consulted. A success with **that same identity** clears its count.
@@ -852,13 +856,41 @@ Behaviours worth knowing before enabling it:
own `GetNodeMetadata` (so **with the flag off too**, i.e. this predates #826) and in the #826 check. A burst of more
than 4096 events under one hold of `Lock`, starting at a miss (a new item, or right after a deploy invalidated the
verdicts), therefore deadlocked the node manager: `FolderEventFloodWireTests` stalled at exactly 4097 events, flag on
and off. Condition events now go through `ReportEventWithoutBlocking`: `ReportEventAsync` completes inline while the
channels have room (the normal case, unchanged); when one is full that report continues asynchronously, holding no
thread and no lock, and later reports queue behind it so events still leave **in report order**. The deferred work
holds only frozen snapshots — bounded by the burst — and drains once `Lock` is released; what a client receives is
still bounded by its item's queue (oldest discarded). Pinned by `FolderEventFloodWireTests` (6000 events under one
`Lock` hold with publishing disabled: completes, deferred reports drain to zero, the item delivers at most its
revised `QueueSize` in increasing order ending with the last event, and the next event is delivered).
and off. Condition events now go through `ReportEventWithoutBlocking`:
- **Delivery** walks the same path as the SDK's `ReportEventAsync` (the condition's sync `OnReportEvent` → Server-object
items, its own `MonitoredNode2`, then each inverse notifier recursively from a snapshot of the notifier list), and
completes **inline** while the channels have room — the normal case, unchanged. It re-checks **before every hop** that
the condition is still the live instance, and `OnConditionReportEvent` checks the same, so an event that waited past a
deploy that removed or replaced its condition is **dropped, never delivered as a ghost** (a ghost Retain alarm after
the NodeDeleted for ScadaBridge on `i=2253`; with the ACL on, a non-live scripted condition could otherwise be admitted
through its OLD equipment folder, #808).
- **Deferral is per condition.** When a channel is full that event continues asynchronously (no thread, no lock held)
and the condition gets a queue; its later events wait behind it, so **one condition's events stay in report order**,
while conditions whose channels have room are not held up. Events of different conditions may interleave differently
from report order — each carries its condition's full state, so nothing a client derives depends on that.
- **Overflow policy — coalesce, newest wins.** A condition holds at most 1024 waiting events; past that, or while more
than 65 536 are deferred in total, its **oldest not-yet-started** event is discarded. A condition event carries the
condition's complete state, so for every Retain-based consumer the newest supersedes the rest; what is lost is
intermediate transitions on the OPC UA event stream only (alarm history is recorded on the engine/driver path, not
from these events). Dropping the newest would leave subscribers on a stale state; blocking is the deadlock above. Each
condition always keeps its newest, so the ceiling is 65 536 + the number of conditions.
- **Visibility:** entering deferral logs a **Warning** (at most once a minute) naming the first condition; draining logs
an **Information** line with the episode's deferred/discarded counts. Meters: `otopcua.opcua.condition_events.deferred`
(live depth, up/down) and `otopcua.opcua.condition_events.discarded{reason=coalesced|not_live}`.
- **SDK behaviour verified (2.0.1, decompiled):** `NodeState.ReportEventAsync`/`GetNotifiers` copy the notifier list under
the node's own notifier lock, so a concurrent `RemoveNotifier` under `Lock` cannot break a deferred walk (it only
changes which folders a later hop reaches — and the liveness check covers removal); `MonitoredNode2.Dispose` completes
its channel writer, which fails a pending `WriteAsync` with `ChannelClosedException` (swallowed by the SDK), so a
disposed node cannot stall a condition's queue; and the delivery task catches per-event exceptions, so it keeps draining.
- ⚠️ **Residual (documented, not fixed): `ConditionRefresh` does not flush deferrals.** While a notifier channel is
overloaded, a client that refreshes may receive older deferred events of a condition after `RefreshEndEvent`. They
are still in that condition's order and its newest event arrives last, so the state a client ends up holding is
correct; making refresh wait for the queues would block a service call on the very drain the overload is stalling.
Pinned by `FolderEventFloodWireTests`: 6000 events under one `Lock` hold with publishing disabled (flag on and off)
complete, drain to zero, coalesce, and the item delivers at most its revised `QueueSize` in increasing order ending
with the last event; and a condition **removed** in the same `Lock` hold has none of its deferred events delivered to
either the folder or the Server-object item (the flag-off variant fails without the liveness checks).
**Model-change and audit events on the Server object are gated too** (Gitea #588). The node manager's
own server-wide events — `GeneralModelChangeEvent` (NodeAdded / NodeDeleted / DataTypeChanged) and the
@@ -154,6 +154,24 @@ public static class OtOpcUaTelemetry
Meter.CreateCounter<long>("otopcua.opcua.apikey_store.bounded", unit: "{lookup}",
description: "API-key store lookups the bound intervened on (outcome=timeout|cache_hit).");
/// <summary>
/// Part 9 condition events whose delivery is currently deferred because an event notifier's SDK delivery
/// channel was full (Gitea #838). Zero in steady state; non-zero means some folder's event subscribers are
/// not keeping up with the alarm event rate (or the node manager lock is held across a large burst).
/// </summary>
public static readonly UpDownCounter<long> ConditionEventReportsDeferred =
Meter.CreateUpDownCounter<long>("otopcua.opcua.condition_events.deferred", unit: "{event}",
description: "Condition events waiting for room in an event notifier's delivery channel.");
/// <summary>
/// Deferred condition events discarded before delivery (Gitea #838): <c>reason=coalesced</c> — the per-condition
/// deferral budget was exceeded and the condition's oldest not-yet-started event gave way to its newest;
/// <c>reason=not_live</c> — the condition was removed or replaced before its deferred event was delivered.
/// </summary>
public static readonly Counter<long> ConditionEventReportsDiscarded =
Meter.CreateCounter<long>("otopcua.opcua.condition_events.discarded", unit: "{event}",
description: "Deferred condition events discarded before delivery (reason=coalesced|not_live).");
// ---------------- Convenience helpers ----------------
/// <summary>
@@ -168,8 +168,23 @@ public sealed class ApiKeyOpcUaUserAuthenticator : IOpcUaUserAuthenticator
// lmxopcua#844 — the key id is public (it is the lookup index, never the secret), so guesses at different keys
// are counted apart and a client's own successful key cannot clear the count of a key it is guessing at.
// A credential that is not a well-formed key falls back to the username (all such guesses share one count).
public string DescribeCredential(string username, string password) =>
ApiKeySecret.TryParse(password, out var apiKeyId, out _) ? "key:" + apiKeyId : "user:" + username;
//
// #844 review: the identity is CANONICAL, never the raw presented id. ConfigDbApiKeyStore matches the id with SQL
// Server `=` under a case-insensitive collation that also ignores trailing spaces, so `otua_abc…`, `otua_ABC…` and
// `otua_abc… ` reach the same key row — as separate lockout identities they would each get a fresh per-credential
// count. Minted ids are lowercase hex (ApiKeySecret.Generate), so: trim, lowercase, and fold every id outside that
// charset (which no real key can carry) into one shared identity. The username is folded the same way. Merging
// only ever makes the per-credential count stricter; the per-client cap is unaffected.
public string DescribeCredential(string username, string password)
{
if (!ApiKeySecret.TryParse(password, out var apiKeyId, out _))
return "user:" + (username ?? string.Empty).Trim().ToLowerInvariant();
var canonical = apiKeyId.Trim().ToLowerInvariant();
return canonical.Length > 0 && canonical.All(char.IsAsciiHexDigitLower)
? "key:" + canonical
: "key:<not-a-minted-id>";
}
/// <inheritdoc />
// Resolves the <c>anonymous</c> principal's grants through the same mapper a key's principal
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
@@ -1400,22 +1401,44 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
}
}
/// <summary>Serializes <see cref="_eventReportChain"/>.</summary>
private readonly object _eventReportGate = new();
/// <summary>The tail of the deferred condition-event reports (Gitea #838); completed when none is pending.</summary>
private Task _eventReportChain = Task.CompletedTask;
private int _deferredEventReports;
/// <summary>Condition-event reports currently deferred because a notifier's delivery channel was full (test observation).</summary>
internal int DeferredEventReports => Volatile.Read(ref _deferredEventReports);
/// <summary>The most deferred events one condition may hold before its oldest not-yet-started one gives way (#838).</summary>
internal const int MaxDeferredEventsPerCondition = 1024;
/// <summary>
/// Reports a condition event through the condition's notifier chain WITHOUT blocking the caller, in report
/// order (Gitea #838).
/// The total deferred events across all conditions beyond which each new deferral coalesces its condition's
/// queue down to its newest event (#838). Each condition always keeps its newest, so the true ceiling is this
/// plus the number of conditions.
/// </summary>
/// <param name="source">The condition.</param>
internal const int MaxDeferredEventsTotal = 65_536;
private static readonly TimeSpan DeferralWarningInterval = TimeSpan.FromMinutes(1);
/// <summary>Guards <see cref="_deferredReports"/> and the deferral episode bookkeeping. Never held across a delivery.</summary>
private readonly object _eventReportGate = new();
/// <summary>Conditions with deferred events, each with its own ordered queue (#838). Keyed by instance.</summary>
private readonly Dictionary<AlarmConditionState, DeferredConditionReports> _deferredReports =
new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
private int _deferredEventReports;
private long _discardedEventReports;
private DateTime _lastDeferralWarningUtc = DateTime.MinValue;
private bool _episodeWarned;
private long _episodeDeferred;
private long _episodeDiscarded;
/// <summary>Condition events currently deferred — queued or in flight (test observation).</summary>
internal int DeferredEventReports => Volatile.Read(ref _deferredEventReports);
/// <summary>Deferred condition events discarded before delivery, any reason (test observation).</summary>
internal long DiscardedEventReports => Interlocked.Read(ref _discardedEventReports);
/// <summary>
/// Reports a condition event through the condition's notifier chain WITHOUT blocking the caller, in the order
/// this condition's events were reported, and never for a condition that is no longer live (Gitea #838).
/// </summary>
/// <param name="condition">The condition (must be the live instance — every caller holds <c>Lock</c>).</param>
/// <param name="e">The frozen snapshot of the event.</param>
/// <remarks>
/// <para>
@@ -1423,72 +1446,212 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
/// <c>MonitoredNode2</c> whose delivery task drains a BOUNDED channel (4096, <c>FullMode = Wait</c>), and
/// the synchronous <c>ReportEvent</c> BLOCKS when that channel is full. Condition events are reported
/// under <c>Lock</c>, and the delivery task takes <c>Lock</c> on every per-event verdict-cache miss (the
/// SDK's own <c>GetNodeMetadata</c>, and #826's <see cref="IsNotifierEventVisibleTo"/>). So a burst of more
/// than 4096 events under one hold of <c>Lock</c>, starting at a miss, parked the consumer on <c>Lock</c>
/// while the producer waited on the consumer: a deadlock that froze the whole node manager (pinned by
/// <c>FolderEventFloodWireTests</c>, which stalled at exactly 4097 events before this).
/// SDK's own <c>GetNodeMetadata</c> — flag off too — and #826's <see cref="IsNotifierEventVisibleTo"/>). So
/// a burst of more than 4096 events under one hold of <c>Lock</c>, starting at a miss, deadlocked the
/// node manager (<c>FolderEventFloodWireTests</c> stalled at exactly 4097 events before this).
/// </para>
/// <para>
/// <b>What this does.</b> The common case is unchanged and synchronous: <c>ReportEventAsync</c> completes
/// inline while every channel has room. Only when a channel is full does that report continue
/// asynchronously (awaiting channel space, holding no thread and no lock), and every later report queues
/// behind it, so events still leave in the order they were reported. The deferred work holds only frozen
/// snapshots, so it is bounded by the burst that caused it and drains once <c>Lock</c> is released.
/// Delivery to clients stays bounded by each monitored item's own queue (oldest discarded). A deferred
/// report runs the condition's synchronous <c>OnReportEvent</c> hook (<see cref="OnConditionReportEvent"/>)
/// off <c>Lock</c>; that hook takes <c>Lock</c> itself where it reads guarded state.
/// <b>Delivery.</b> <see cref="DeliverConditionEventAsync"/> walks the same path the SDK's
/// <c>ReportEventAsync</c> does (the node's sync <c>OnReportEvent</c>, its <c>MonitoredNode2</c>, then each
/// inverse notifier recursively, from a snapshot of the notifier list) — but re-checks before EVERY hop that
/// the condition is still the live instance, so an event deferred past a deploy that removed or replaced
/// it is dropped rather than delivered as a ghost (review of 91e28e87). The common case is unchanged and
/// synchronous: the walk completes inline while every channel has room.
/// </para>
/// <para>
/// <b>Deferral.</b> When a channel is full the walk continues asynchronously (holding no thread or lock),
/// and the condition gets a queue: its later events wait behind it, so ONE condition's events stay in
/// order, while other conditions whose channels have room are not held up (per-condition, not global).
/// Events of different conditions may interleave differently than reported — each carries its own
/// condition's full state, so nothing a client derives depends on cross-condition order.
/// </para>
/// <para>
/// <b>Overflow policy: coalesce, newest wins.</b> A condition holds at most
/// <see cref="MaxDeferredEventsPerCondition"/> waiting events; past that, or while the total exceeds
/// <see cref="MaxDeferredEventsTotal"/>, its OLDEST not-yet-started event is discarded and counted
/// (<c>otopcua.opcua.condition_events.discarded{reason=coalesced}</c>). A condition event carries the
/// condition's complete state, so the newest supersedes the older ones for every Retain-based consumer
/// (ScadaBridge); what is lost is intermediate transitions on the OPC UA event stream only, never the
/// final state (alarm history is recorded by the engine/driver path, not from these events). Dropping
/// the newest instead would leave subscribers on a stale state; blocking is the deadlock above.
/// </para>
/// <para>
/// <b>Visibility.</b> Entering deferral logs a Warning (at most once a minute), leaving it an Information
/// line with the episode's counts; <c>otopcua.opcua.condition_events.deferred</c> is the live depth.
/// </para>
/// </remarks>
private void ReportEventWithoutBlocking(NodeState source, IFilterTarget e)
private void ReportEventWithoutBlocking(AlarmConditionState condition, IFilterTarget e)
{
lock (_eventReportGate)
{
if (_eventReportChain.IsCompleted)
if (_deferredReports.TryGetValue(condition, out var queued))
{
var report = source.ReportEventAsync(SystemContext, e);
if (report.IsCompletedSuccessfully) return;
Interlocked.Increment(ref _deferredEventReports);
_eventReportChain = ObserveEventReportAsync(report.AsTask(), source);
EnqueueDeferred(condition, queued, e);
return;
}
Interlocked.Increment(ref _deferredEventReports);
_eventReportChain = ReportAfterAsync(_eventReportChain, source, e);
}
}
private async Task ReportAfterAsync(Task previous, NodeState source, IFilterTarget e)
{
await previous.ConfigureAwait(false); // never faults: every link observes its own failure
Task report;
// Every caller holds Lock, so no other report of this condition can start until this returns; the gate is
// NOT held across the delivery (the delivery may take Lock, and gate→Lock against Lock→gate would deadlock).
ValueTask delivery;
try
{
report = source.ReportEventAsync(SystemContext, e).AsTask();
delivery = DeliverConditionEventAsync(condition, condition, e);
}
catch (Exception ex)
{
report = Task.FromException(ex);
delivery = ValueTask.FromException(ex);
}
await ObserveEventReportAsync(report, source).ConfigureAwait(false);
if (delivery.IsCompletedSuccessfully) return;
lock (_eventReportGate)
{
var queue = new DeferredConditionReports();
_deferredReports[condition] = queue;
CountDeferred(+1, condition);
_ = DrainDeferredAsync(condition, queue, delivery.AsTask());
}
}
private async Task ObserveEventReportAsync(Task report, NodeState source)
/// <summary>Queues a later event behind a condition's deferred one, coalescing past the budget. Under the gate.</summary>
private void EnqueueDeferred(AlarmConditionState condition, DeferredConditionReports queue, IFilterTarget e)
{
try
queue.Pending.Enqueue(e);
CountDeferred(+1, condition);
while (queue.Pending.Count > 1
&& (queue.Pending.Count > MaxDeferredEventsPerCondition || _deferredEventReports > MaxDeferredEventsTotal))
{
await report.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report Part 9 condition event for {NodeId}", source.NodeId);
}
finally
{
Interlocked.Decrement(ref _deferredEventReports);
queue.Pending.Dequeue();
CountDeferred(-1, condition);
CountDiscarded("coalesced");
}
}
/// <summary>Delivers a condition's deferred events in order, one at a time, then forgets the queue.</summary>
private async Task DrainDeferredAsync(AlarmConditionState condition, DeferredConditionReports queue, Task inFlight)
{
var current = inFlight;
while (true)
{
try
{
await current.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "OtOpcUaNodeManager: failed to report Part 9 condition event for {NodeId}", condition.NodeId);
}
IFilterTarget next;
lock (_eventReportGate)
{
CountDeferred(-1, condition);
if (queue.Pending.Count == 0)
{
_deferredReports.Remove(condition);
return;
}
next = queue.Pending.Dequeue();
}
try
{
current = DeliverConditionEventAsync(condition, condition, next).AsTask();
}
catch (Exception ex)
{
current = Task.FromException(ex);
}
}
}
/// <summary>
/// The SDK's <c>NodeState.ReportEventAsync</c> walk — the node's sync <c>OnReportEvent</c>, its async
/// <c>OnReportEventAsync</c> (a <c>MonitoredNode2</c>), then each inverse notifier recursively from a snapshot of
/// the notifier list, then (<c>BaseInstanceState</c>) the Parent — with a liveness check before every hop (#838
/// review). A guard test pins that the SDK still has no other override. <c>EventReported</c> is not
/// raised: nothing in the SDK or this repo subscribes to it, and it cannot be raised from outside the node.
/// </summary>
private async ValueTask DeliverConditionEventAsync(AlarmConditionState condition, NodeState node, IFilterTarget e)
{
if (!IsLiveCondition(condition))
{
// Removed or replaced while this event waited. Counted once, at the event's first hop.
if (ReferenceEquals(node, condition)) CountDiscarded("not_live");
return;
}
node.OnReportEvent?.Invoke(SystemContext, node, e);
if (node.OnReportEventAsync is { } handler)
await handler(SystemContext, node, e, CancellationToken.None).ConfigureAwait(false);
var notifiers = new List<NodeState.Notifier>();
node.GetNotifiers(SystemContext, notifiers);
foreach (var notifier in notifiers)
{
if (notifier.IsInverse && notifier.Node is { } next)
await DeliverConditionEventAsync(condition, next, e).ConfigureAwait(false);
}
// BaseInstanceState.ReportEventAsync (the only override in SDK 2.0.1 — decompiled Types/Server/Core) runs the
// base walk above and THEN reports to its Parent: that is how a condition's events reach the folder it hangs under.
if (node is BaseInstanceState { Parent: { } parent })
await DeliverConditionEventAsync(condition, parent, e).ConfigureAwait(false);
}
/// <summary>Whether <paramref name="condition"/> is the instance the address space currently holds for its NodeId.</summary>
private bool IsLiveCondition(NodeState condition) =>
_alarmConditions.TryGetValue(MapKey(condition.NodeId), out var live) && ReferenceEquals(live, condition);
/// <summary>Adjusts the deferred depth and logs episode entry/exit. Under the gate.</summary>
private void CountDeferred(int delta, AlarmConditionState condition)
{
var before = _deferredEventReports;
_deferredEventReports += delta;
OtOpcUaTelemetry.ConditionEventReportsDeferred.Add(delta);
if (delta > 0) _episodeDeferred += delta;
if (before == 0 && delta > 0)
{
_episodeDeferred = delta;
_episodeDiscarded = 0;
var now = DateTime.UtcNow;
_episodeWarned = now - _lastDeferralWarningUtc >= DeferralWarningInterval;
if (_episodeWarned)
{
_lastDeferralWarningUtc = now;
_logger.LogWarning(
"OtOpcUaNodeManager: condition events are being deferred — an event notifier's delivery channel is "
+ "full (first: {NodeId}). Folder event subscribers are not keeping up, or Lock is held across a large "
+ "burst; events stay ordered per condition, and past {PerCondition} waiting per condition the oldest "
+ "give way to the newest (Gitea #838)",
condition.NodeId, MaxDeferredEventsPerCondition);
}
}
else if (_deferredEventReports == 0 && before > 0 && _episodeWarned)
{
_logger.LogInformation(
"OtOpcUaNodeManager: deferred condition events drained ({Deferred} deferred, {Discarded} discarded this episode)",
_episodeDeferred, _episodeDiscarded);
}
}
private void CountDiscarded(string reason)
{
Interlocked.Increment(ref _discardedEventReports);
lock (_eventReportGate) _episodeDiscarded++;
OtOpcUaTelemetry.ConditionEventReportsDiscarded.Add(1, new KeyValuePair<string, object?>("reason", reason));
}
/// <summary>One condition's deferred events: the one in flight is held by the drain loop, the rest wait here.</summary>
private sealed class DeferredConditionReports
{
public readonly Queue<IFilterTarget> Pending = new();
}
/// <summary>
/// The gate-relevant slice of a Part 9 condition's state — exactly the fields that drive a
/// condition event AND that an <see cref="AlarmConditionSnapshot"/> can change. As a record, two
@@ -5316,6 +5479,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
{
if (_allEventsItems.IsEmpty) return;
// #838 review: never for a condition a deploy has removed or replaced. The hook stays attached to a torn-down
// instance, and an event reported off Lock (a deferred report, an SDK method audit racing a teardown) would
// otherwise hand Server-object subscribers a ghost Retain alarm after its NodeDeleted — and, with the ACL on,
// a non-live scripted condition skips ConditionVisibilityScopes' "condition alone" rule and could be admitted
// through its OLD equipment folder (#808).
if (!IsLiveCondition(node)) return;
var scopes = AclEnforced ? ConditionVisibilityScopes(node) : null;
var reportingSession = (context as ISessionSystemContext)?.SessionId;
@@ -192,6 +192,28 @@ public sealed class ApiKeyAuthenticationTests
auth.DescribeCredential("apikey", "not-a-key").ShouldBe("user:apikey");
}
/// <summary>
/// #844 review — ConfigDbApiKeyStore matches the key id with SQL Server <c>=</c> (case-insensitive, trailing
/// spaces ignored), so case / trailing-space variants of one key id reach the same key and must be ONE lockout
/// identity; ids outside the minted lowercase-hex charset (no real key carries one) share a single identity.
/// </summary>
[Fact]
public void Credential_identities_are_canonical_so_variants_of_one_key_id_share_a_count()
{
var auth = Auth(SetUp().Store);
const string id = "0123456789abcdef";
auth.DescribeCredential("apikey", $"otua_{id}_s1").ShouldBe($"key:{id}");
auth.DescribeCredential("apikey", $"otua_{id.ToUpperInvariant()}_s2").ShouldBe($"key:{id}");
auth.DescribeCredential("apikey", $"otua_{id} _s3").ShouldBe($"key:{id}");
auth.DescribeCredential("apikey", $"otua_ {id.ToUpperInvariant()} _s4").ShouldBe($"key:{id}");
auth.DescribeCredential("apikey", "otua_not-hex_s").ShouldBe("key:<not-a-minted-id>");
auth.DescribeCredential("apikey", "otua_zzzz_s").ShouldBe(auth.DescribeCredential("apikey", "otua_yyyy_s"));
auth.DescribeCredential("APIKEY ", "garbage").ShouldBe("user:apikey");
}
[Fact]
public async Task An_unset_pepper_denies_rather_than_authenticating_everything()
{
@@ -104,6 +104,8 @@ public sealed class FolderEventFloodWireTests
flooded.Count.ShouldBeLessThanOrEqualTo((int)onA.Status.QueueSize, "delivery is bounded by the item's queue, not the flood");
flooded.ShouldBe(flooded.Order().ToList(), "events leave in the order they were reported");
flooded[^1].ShouldBe(FloodSize - 1);
rig.Nm.DiscardedEventReports.ShouldBeGreaterThan(0,
"past the per-condition budget the oldest waiting events give way to the newest (coalesced)");
// And the node manager is live: a later event is decided by the per-folder ACL and delivered.
rig.Project(native: true, State("after"));
@@ -121,6 +123,65 @@ public sealed class FolderEventFloodWireTests
}
}
/// <summary>
/// #838 review — events deferred behind a full channel must not outlive their condition. A deploy that removes
/// the condition while its events wait used to let them reach Server-object (<c>i=2253</c>) subscribers after the
/// NodeDeleted — a ghost Retain alarm for ScadaBridge — and the folder subscribers too. Every hop of a deferred
/// report now re-checks that the condition is still the live instance.
/// </summary>
/// <param name="enforced">Per-folder / Server-object ACL on or off (off is the production default).</param>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Deferred_events_of_a_condition_removed_while_they_wait_are_never_delivered(bool enforced)
{
var ct = TestContext.Current.CancellationToken;
await using var rig = await ServerEventRig.StartAsync("Folder838Ghost", encrypted: false, ct);
rig.KeyRoles = [];
rig.MaterialiseNative();
rig.Enforce(enforced, [ServerEventRig.Key()], KeyEquipmentGrant(EqA));
using var keyed = await rig.ConnectAsync(anonymous: false, ct);
var events = new RecordedEvents();
var subscription = await RecordedEvents.SubscribeAsync(keyed, events, ct);
var onA = RecordedEvents.AddEventItem(subscription, ServerEventRig.UnsNodeId(keyed, EqA));
var onServer = RecordedEvents.AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
onA.Status.Error.ShouldBeNull();
onServer.Status.Error.ShouldBeNull();
await subscription.SetPublishingModeAsync(false, ct);
// One Lock hold: flood past the channel (the tail is deferred), then the deploy removes the condition.
await Task.Factory.StartNew(() =>
{
lock (rig.Nm.Lock)
{
for (var i = 0; i < FloodSize; i++) rig.Project(native: true, State($"f{i}"));
rig.Nm.DeferredEventReports.ShouldBeGreaterThan(0, "the flood must actually have deferred events");
rig.Nm.RemoveAlarmConditionNode(ServerEventRig.RawAlarmPath, AddressSpaceRealm.Raw).ShouldBeTrue();
}
}, ct, TaskCreationOptions.LongRunning, TaskScheduler.Default).WaitAsync(TimeSpan.FromSeconds(60), ct);
await RecordedEvents.WaitUntilAsync(() => rig.Nm.DeferredEventReports == 0);
rig.Nm.DiscardedEventReports.ShouldBeGreaterThan(0, "the waiting events were dropped, not delivered");
await Task.Delay(TimeSpan.FromSeconds(2), ct);
await subscription.SetPublishingModeAsync(true, ct);
await Task.Delay(TimeSpan.FromSeconds(2), ct);
int[] Flooded(MonitoredItem item) => events.Of(item)
.Select(RecordedEvents.Message)
.Where(m => m?.StartsWith('f') == true)
.Select(m => int.Parse(m![1..], System.Globalization.CultureInfo.InvariantCulture))
.ToArray();
// Positive control: the events reported before the channel filled did arrive (Server object: synchronously at
// the condition's own hop).
Flooded(onServer).ShouldNotBeEmpty();
// None of the deferred tail — which was still waiting when the condition was removed — reached anyone.
Flooded(onServer).Max().ShouldBeLessThan(FloodSize - 1, "no ghost reached the Server-object subscriber");
Flooded(onA).DefaultIfEmpty(-1).Max().ShouldBeLessThan(FloodSize - 1, "no ghost reached the folder subscriber");
}
private static AlarmConditionSnapshot State(string message) =>
new(true, false, false, true, AlarmShelvingKind.Unshelved, 700, message);
@@ -0,0 +1,41 @@
using System.Reflection;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Gitea #838 review: <c>OtOpcUaNodeManager.DeliverConditionEventAsync</c> re-implements the SDK's event walk
/// (<c>NodeState.ReportEventAsync</c>, plus <c>BaseInstanceState</c>'s forward to its Parent) so it can re-check the
/// condition's liveness before every hop. That copy is only faithful while no other node type changes the walk —
/// missing the Parent hop alone silently stopped condition events reaching their folders. This pins the set of
/// types that define <c>ReportEvent</c> / <c>ReportEventAsync</c> across the SDK and this server.
/// </summary>
public sealed class ConditionEventWalkGuardTests
{
[Fact]
public void Only_NodeState_and_BaseInstanceState_define_the_event_walk()
{
Assembly[] assemblies =
[
typeof(NodeState).Assembly, // Opc.Ua.Types
typeof(ApplicationConfiguration).Assembly, // Opc.Ua.Core
typeof(StandardServer).Assembly, // Opc.Ua.Server
typeof(OtOpcUaNodeManager).Assembly, // this server
];
var definers = assemblies.Distinct()
.SelectMany(a => a.GetTypes())
.Where(t => typeof(NodeState).IsAssignableFrom(t))
.Where(t => t.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)
.Any(m => m.Name is nameof(NodeState.ReportEvent) or nameof(NodeState.ReportEventAsync)))
.Select(t => t.FullName)
.Order()
.ToArray();
definers.ShouldBe(["Opc.Ua.BaseInstanceState", "Opc.Ua.NodeState"],
"a new override changes the event walk — mirror it in OtOpcUaNodeManager.DeliverConditionEventAsync");
}
}