Merge #866 #870 #871 #872: MxStatus detail vocabulary + quality grading, ring event queue, CI races
v2-ci / unit-tests (push) Waiting to run
v2-ci / unit-tests (push) Waiting to run
Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
@@ -217,7 +217,7 @@ Two things to know before relying on any of this:
|
||||
replay off `Lock` one event per hold of a per-item gate, and withholds older deferred events from THAT item only
|
||||
(re-sent condition, or Retain=true of one omitted because NOT retained — never one omitted by ACL). Item state is a
|
||||
`ConditionalWeakTable` keyed by the item; cuts clear when the deferral count hits 0. Tests:
|
||||
`ConditionRefreshDeferralWireTests`. ⚠️ **Since #855 folder items are delivered per item too:** at every notifier we own, `DeliverConditionEventAsync` serves the `MonitoredNode2`'s attached items itself (`DeliverToNotifierItems`: audit guard → `INodeManager3.ValidateEventRolePermissions` per event, UNCACHED → item gate/cut → `QueueEvent`), never through the SDK channel — so no notifier of ours defers, a flood under `Lock` defers nothing, and folder refreshes (`RefreshDirectItems`, flag on AND off) share the #854 cut. The deferral queue + cut remain as the safety net; an event whose walk suspends is marked deferred BEFORE suspending. Tests produce deferral with `NotifierStall` (the `NotifierDeliveryStall` probe) — holding a folder item's SDK lock now blocks the reporter. `FolderConditionRefreshWireTests`; `docs/plans/2026-11-11-folder-event-per-item-delivery.md`. Folder events follow Part 3's shape —
|
||||
`ConditionRefreshDeferralWireTests`. ⚠️ **Since #855 folder items are delivered per item too:** at every notifier we own, `DeliverConditionEventAsync` serves the `MonitoredNode2`'s attached items itself (`DeliverToNotifierItems`: audit guard → `INodeManager3.ValidateEventRolePermissions` per event, UNCACHED → item gate/cut → `QueueEvent`), never through the SDK channel — so no notifier of ours defers, a flood under `Lock` defers nothing, and folder refreshes (`RefreshDirectItems`, flag on AND off) share the #854 cut. The deferral queue + cut remain as the safety net; an event whose walk suspends is marked deferred BEFORE suspending. Tests produce deferral with `NotifierStall` (the `NotifierDeliveryStall` probe) — holding a folder item's SDK lock now blocks the reporter. `FolderConditionRefreshWireTests`; `docs/plans/2026-11-11-folder-event-per-item-delivery.md`. Because that queueing runs under `Lock`, event item queues are `RingEventMonitoredItemQueue` (#870, supplied by `OtOpcUaSdkServer.CreateMonitoredItemQueueFactory` via `RingEventQueueFactory`; data-change queues stay the SDK's) — the SDK queue's discard-oldest was a `List.RemoveAt(0)` per stalled item per report. It is storage only (overflow events + DiscardOldest live in the SDK's unchanged `EventQueueHandler`); parity pinned by `RingEventMonitoredItemQueueParityTests` — re-check them on an SDK bump. 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`. Overflow WITH the ACL doing work (#838
|
||||
item 2) — denied condition interleaved, folder + `i=2253` items, non-publishing client, concurrent Read, grant revoked
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics.Metrics;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
@@ -512,7 +513,12 @@ internal sealed class EventPump : IAsyncDisposable
|
||||
ServerTimestampUtc: DateTime.UtcNow);
|
||||
}
|
||||
|
||||
var statusCode = ev.Statuses.Count > 0
|
||||
// A failure status row wins (it names why the read failed); otherwise the item's OPC DA quality grades
|
||||
// the value. The gateway fills Statuses on every data change, so keying on "any statuses present" meant
|
||||
// Quality was never read and an off-scan / Bad / Uncertain attribute with an OK status row was
|
||||
// published Good (#866 review). Every gateway path (live OnDataChange, value-cache replay, session
|
||||
// snapshot) carries Quality.
|
||||
var statusCode = ev.Statuses.Count > 0 && !ev.Statuses[0].IsSuccess()
|
||||
? StatusCodeMap.FromMxStatus(ev.Statuses[0], _logger)
|
||||
: StatusCodeMap.FromQualityByte((byte)(ev.Quality & 0xFF), _logger);
|
||||
|
||||
|
||||
@@ -107,11 +107,11 @@ internal static class StatusCodeMap
|
||||
|
||||
/// <summary>
|
||||
/// Map a gateway-reported <see cref="MxStatusProxy"/> to OPC UA StatusCode. Uses
|
||||
/// <see cref="MxStatusProxyExtensions.IsSuccess"/> (category == OK AND success != 0)
|
||||
/// as the canonical success test — the proto contract explicitly documents that
|
||||
/// <see cref="MxStatusProxyExtensions.IsSuccess"/> (category == OK; the client library checks the
|
||||
/// category only) as the canonical success test — the proto contract explicitly documents that
|
||||
/// <c>success</c> is NOT a boolean and must not be checked in isolation; category is
|
||||
/// the authoritative indicator. On failure, the detail byte (OPC DA quality substatus)
|
||||
/// drives the specific code, with a transport-error fallback for pre-MXAccess failures.
|
||||
/// the authoritative indicator. On failure, the <c>MxStatusDetail</c> code drives the specific
|
||||
/// code, then the category, with a transport-error fallback for pre-MXAccess failures.
|
||||
/// </summary>
|
||||
/// <param name="status">The MX status proxy to convert, or null for Good status.</param>
|
||||
/// <param name="logger">Optional logger for diagnostics on unknown status codes.</param>
|
||||
@@ -121,58 +121,63 @@ internal static class StatusCodeMap
|
||||
if (status is null) return Good;
|
||||
if (status.IsSuccess()) return Good;
|
||||
|
||||
// A detail in [1, 255] is an OPC DA quality substatus (read/data-change rows). Anything
|
||||
// larger is an MXAccess MxStatus detail code — the write-completion rows the gateway
|
||||
// correlates onto write replies carry these (e.g. 1007 operational, 1008 security) — and
|
||||
// truncating one into the quality table maps a REFUSED WRITE to Good whenever the low byte
|
||||
// lands in the Good band (1007 → 239 ≥ 192). Route those on the status category instead.
|
||||
var detail = status.Detail;
|
||||
if (detail is > 0 and <= 0xFF)
|
||||
{
|
||||
var mapped = FromQualityByte((byte)detail, logger);
|
||||
// Invariant: a row that is not IsSuccess() must never surface as Good — a
|
||||
// contradictory Good-band substatus on a failure row falls back to the category.
|
||||
if ((mapped >> 30) != 0u) return mapped;
|
||||
return FromCategory(status, logger);
|
||||
}
|
||||
if (detail > 0xFF) return FromCategory(status, logger);
|
||||
|
||||
// detected_by != Mxaccess (raw_detected_by != the MXAccess source enum) implies
|
||||
// the failure happened pre-call (gateway, worker, transport) — surface as a
|
||||
// communication error rather than a generic Bad.
|
||||
if (status.RawDetectedBy != 0) return BadCommunicationError;
|
||||
|
||||
return Bad;
|
||||
// The detail is MXAccess's MxStatusDetail code verbatim (mxaccessgw's MxStatusProxyConverter copies
|
||||
// MXSTATUS_PROXY.detail straight through; vocabulary in its gateway.md) — NEVER an OPC DA quality
|
||||
// byte. It used to be routed through the quality table whenever it was <= 0xFF, which mis-named
|
||||
// every low code: MX_E_PlatformCommunicationError (2) logged as "quality byte 0x02" and fell to
|
||||
// generic Bad (#866, a stopped platform's ScanState on VD03), MX_E_InvalidObjectId (8) surfaced as
|
||||
// BadNotConnected, MX_E_Overflow (20) as BadCommunicationError. A failure row is also never Good or
|
||||
// Uncertain, whatever its detail.
|
||||
//
|
||||
// Detail 0 routes the same way. It used to become BadCommunicationError whenever raw_detected_by != 0, on
|
||||
// the belief that a non-zero source meant a pre-MXAccess transport failure — but every detected_by value
|
||||
// names an MXAccess layer (0 requesting LMX, 1 responding LMX, 2/3 NMX, 4/5 automation object; the
|
||||
// gateway's own failures arrive as ProtocolStatus, never as a status row), so the category decides (#872).
|
||||
return FromCategory(status, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a failure row whose detail field is an MxStatus detail code rather than an OPC DA
|
||||
/// quality byte (or contradicts the failure). Per-code first — the <c>MxStatusDetail</c>
|
||||
/// vocabulary is published in mxaccessgw's gateway.md ("MxStatus Detail Vocabulary",
|
||||
/// <c>75c71ad</c>, lifted from <c>Interop.aaMxDataConsumer.MxStatusDetail</c>) — then the
|
||||
/// category bucket. Codes ≤ 0xFF (e.g. 1 = MX_E_RequestTimedOut) never reach here: they are
|
||||
/// value-ambiguous with the quality-byte space and take the quality path, which still lands
|
||||
/// them in the Bad family.
|
||||
/// Map a failure row by its <c>MxStatusDetail</c> code — the vocabulary is published in mxaccessgw's
|
||||
/// gateway.md ("MxStatus Detail Vocabulary", lifted from <c>Interop.aaMxDataConsumer.MxStatusDetail</c>)
|
||||
/// — then by the status category for codes with no specific OPC UA counterpart.
|
||||
/// </summary>
|
||||
private static uint FromCategory(MxStatusProxy status, ILogger? logger)
|
||||
{
|
||||
switch (status.Detail)
|
||||
{
|
||||
case 1004: return BadOutOfRange; // MX_E_DataOutOfRange
|
||||
case 1: return BadTimeout; // MX_E_RequestTimedOut
|
||||
case 2: // MX_E_PlatformCommunicationError (live: stopped platform, #866)
|
||||
case 5: return BadCommunicationError; // MX_E_EngineCommunicationError
|
||||
case 3: // MX_E_InvalidPlatformId
|
||||
case 4: // MX_E_InvalidEngineId
|
||||
case 6: // MX_E_InvalidReference
|
||||
case 8: // MX_E_InvalidObjectId
|
||||
case 9: // MX_E_ObjectSignatureMismatch
|
||||
case 10: // MX_E_AttributeSignatureMismatch
|
||||
case 1000: // MX_E_InvalidPrimitiveId
|
||||
case 1001: // MX_E_InvalidAttributeId
|
||||
case 1002: return BadConfigurationError; // MX_E_InvalidPropertyId
|
||||
case 13: // MX_E_WrongDataType
|
||||
case 18: // MX_E_ConversionNotSupported
|
||||
case 19: // MX_E_UnableToConvertString
|
||||
case 1005: return BadTypeMismatch; // MX_E_IncorrectDataType
|
||||
case 20: // MX_E_Overflow
|
||||
case 1004: return BadOutOfRange; // MX_E_DataOutOfRange
|
||||
case 1006: return BadNotReadable; // MX_E_NotReadable
|
||||
case 1007: return BadNotWritable; // MX_E_NotWriteable (live-captured: read-only attr)
|
||||
case 1008: // MX_E_WriteAccessDenied (live-captured: unadvised no-login write)
|
||||
case 1012: // MX_E_SecuredWrite (attribute requires a secured write)
|
||||
case 1013: // MX_E_VerifiedWrite (attribute requires a verified write)
|
||||
case 1014: // MX_E_NoAlarmAckPrivilege
|
||||
case 1016: // MX_E_UserNotHavingAccessRights
|
||||
case 1017: return BadUserAccessDenied; // MX_E_VerifierNotHavingVerifyRights
|
||||
case 1010: // MX_E_ObjectInitializing (transient)
|
||||
case 1011: return BadOutOfService; // MX_E_EngineInitializing (transient)
|
||||
}
|
||||
|
||||
// Everything else — including 8000 MX_E_AutomationObjectSpecificError, deliberately, since
|
||||
// the object supplies its own semantics — falls back to the category bucket.
|
||||
// Everything else — resolving (11/12), repository/engine busy (7/25/26), version/command mismatches,
|
||||
// index errors, 1009 unknown and, deliberately, 8000 MX_E_AutomationObjectSpecificError since the
|
||||
// object supplies its own semantics — falls back to the category bucket.
|
||||
var code = status.Category switch
|
||||
{
|
||||
MxStatusCategory.SecurityError => BadUserAccessDenied,
|
||||
@@ -180,13 +185,19 @@ internal static class StatusCodeMap
|
||||
MxStatusCategory.ConfigurationError => BadConfigurationError,
|
||||
_ => Bad,
|
||||
};
|
||||
logger?.LogWarning(
|
||||
"MxStatus failure row category {Category} detail {Detail} mapped to 0x{Code:X8} at category " +
|
||||
"level. Field capture welcome — extend StatusCodeMap.FromCategory with the detail code.",
|
||||
status.Category, status.Detail, code);
|
||||
// Once per (detail, category) per process: these rows arrive on the data-change path, so a stopped
|
||||
// engine's tags would otherwise log on every update.
|
||||
if (logger is not null && UnmappedDetailsLogged.TryAdd((status.Detail, status.Category), 0))
|
||||
logger.LogWarning(
|
||||
"MxStatus failure row category {Category} detail {Detail} ({DiagnosticText}) mapped to 0x{Code:X8} at " +
|
||||
"category level. Field capture welcome — extend StatusCodeMap.FromCategory with the detail code.",
|
||||
status.Category, status.Detail, status.DiagnosticText, code);
|
||||
return code;
|
||||
}
|
||||
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<(int, MxStatusCategory), byte>
|
||||
UnmappedDetailsLogged = new();
|
||||
|
||||
/// <summary>
|
||||
/// Convert an OPC UA status-code uint back to the OPC DA quality category
|
||||
/// byte — Good=192, Uncertain=64, Bad=0 — by extracting the top-two bits of the
|
||||
|
||||
@@ -314,6 +314,27 @@ public sealed class OtOpcUaSdkServer : StandardServer
|
||||
_otOpcUaNodeManager!.InstallApplicationAuditPermissions(auditType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test/measurement switch (Gitea #870): false makes servers started afterwards use the SDK's own event queues, so
|
||||
/// the perf harness can A/B the two in one process. Never cleared in production.
|
||||
/// </summary>
|
||||
internal static bool RingEventQueues { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// The SDK's factory with event queues swapped for <see cref="RingEventMonitoredItemQueue"/> (Gitea #870: since #855
|
||||
/// folder event items are queued under <c>Lock</c>, and the SDK queue's discard-oldest is an O(QueueSize) shift).
|
||||
/// A factory set through <see cref="StandardServer.MonitoredItemQueueFactory"/> is used as-is.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
protected override IMonitoredItemQueueFactory? CreateMonitoredItemQueueFactory(
|
||||
IServerInternal server, ApplicationConfiguration configuration)
|
||||
{
|
||||
var factory = base.CreateMonitoredItemQueueFactory(server, configuration);
|
||||
if (factory is null || ReferenceEquals(factory, MonitoredItemQueueFactory) || !RingEventQueues) return factory;
|
||||
// Server-owned (OwnsMonitoredItemQueueFactory: not the property's instance), so disposed on stop with the SDK's.
|
||||
return new RingEventQueueFactory(factory);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override MasterNodeManager CreateMasterNodeManager(
|
||||
IServerInternal server, ApplicationConfiguration configuration)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// A ring-buffer replacement for the SDK's <see cref="EventMonitoredItemQueue"/> (Gitea #870): the same observable
|
||||
/// behaviour, but discarding the oldest event at a full queue is O(1) instead of a <c>List.RemoveAt(0)</c> shift of
|
||||
/// the whole queue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why.</b> Since #855 condition events are queued to folder event items by the reporter, under the node
|
||||
/// manager's <c>Lock</c>. At a full queue with DiscardOldest the SDK's <see cref="EventQueueHandler"/> dequeues the
|
||||
/// head and enqueues the new event; the SDK queue's dequeue is <c>List.RemoveAt(0)</c>, ~1 µs per item at
|
||||
/// QueueSize 1000 and ~10 µs at 10 000 — paid per stalled item per report while <c>Lock</c> is held.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What is reproduced (SDK 2.0.1, decompiled).</b> The SDK queue is pure storage — DiscardOldest, the
|
||||
/// <c>EventQueueOverflowEventType</c> positions and the discarded/overflow counters all live in
|
||||
/// <see cref="EventQueueHandler"/>, which is unchanged and drives this queue exactly as it drives the SDK's.
|
||||
/// Reproduced here: <see cref="QueueSize"/> starts at 0; <see cref="Enqueue"/> at QueueSize 0 throws
|
||||
/// <see cref="ServiceResultException"/> <c>BadInternalError</c>, and at <c>Count == QueueSize</c> drops the oldest
|
||||
/// entry first; <see cref="Dequeue"/> is FIFO and returns false (value null) when empty;
|
||||
/// <see cref="SetQueueSize"/> keeps the newest <c>queueSize</c> entries when <c>discardOldest</c>, else the
|
||||
/// oldest; <see cref="IsEventContainedInQueue"/> checks the newest ≤ 1000 entries with the SDK's own
|
||||
/// <see cref="EventMonitoredItemQueue.IsEventRaisedFor"/> (null entries skipped); <see cref="IsDurable"/> is
|
||||
/// false; <see cref="Dispose()"/> does nothing observable (the SDK's does nothing at all). A durable queue is
|
||||
/// never created here — <see cref="RingEventQueueFactory"/> hands that request to the SDK factory, which refuses it
|
||||
/// exactly as before.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not thread-safe, like the SDK's: <see cref="EventQueueHandler"/> is only used under its monitored item's lock.
|
||||
/// Parity is pinned by <c>RingEventMonitoredItemQueueParityTests</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RingEventMonitoredItemQueue : IEventMonitoredItemQueue
|
||||
{
|
||||
/// <summary>The SDK's <c>kMaxNoOfEntriesCheckedForDuplicateEvents</c>.</summary>
|
||||
internal const int MaxEntriesCheckedForDuplicates = 1000;
|
||||
|
||||
private const int MinimumCapacity = 4;
|
||||
|
||||
private EventFieldList?[] _buffer = [];
|
||||
private int _head;
|
||||
private int _count;
|
||||
|
||||
/// <summary>Creates an empty queue (QueueSize 0, as the SDK's).</summary>
|
||||
/// <param name="monitoredItemId">The monitored item the queue belongs to.</param>
|
||||
public RingEventMonitoredItemQueue(uint monitoredItemId) => MonitoredItemId = monitoredItemId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public uint MonitoredItemId { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsDurable => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public uint QueueSize { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public int ItemsInQueue => _count;
|
||||
|
||||
/// <summary>Allocated slots (test observation: the buffer grows with use and shrinks with the queue size).</summary>
|
||||
internal int Capacity => _buffer.Length;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetQueueSize(uint queueSize, bool discardOldest)
|
||||
{
|
||||
QueueSize = queueSize;
|
||||
if (_count > queueSize)
|
||||
{
|
||||
var excess = _count - (int)queueSize;
|
||||
if (discardOldest)
|
||||
{
|
||||
for (var i = 0; i < excess; i++) DropHead();
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < excess; i++)
|
||||
{
|
||||
_buffer[Slot(_count - 1)] = null;
|
||||
_count--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not observable: release a buffer much larger than the new size (a List would keep it).
|
||||
if (_buffer.Length > MinimumCapacity && _buffer.Length > 2 * Math.Max(queueSize, (uint)MinimumCapacity))
|
||||
Resize(Math.Max(_count, MinimumCapacity));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEventContainedInQueue(IFilterTarget instance)
|
||||
{
|
||||
var oldest = _count > MaxEntriesCheckedForDuplicates ? _count - MaxEntriesCheckedForDuplicates : 0;
|
||||
for (var i = _count - 1; i >= oldest; i--)
|
||||
{
|
||||
var queued = _buffer[Slot(i)];
|
||||
if (queued is not null && EventMonitoredItemQueue.IsEventRaisedFor(queued, instance)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Enqueue(EventFieldList value)
|
||||
{
|
||||
if (QueueSize == 0)
|
||||
throw new ServiceResultException(StatusCodes.BadInternalError, "Error queueing Event. Queue size is set to 0");
|
||||
|
||||
if (_count == QueueSize) DropHead();
|
||||
|
||||
if (_count == _buffer.Length)
|
||||
Resize((int)Math.Min(QueueSize, (ulong)Math.Max(MinimumCapacity, _buffer.Length * 2L)));
|
||||
|
||||
_buffer[Slot(_count)] = value;
|
||||
_count++;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Dequeue(out EventFieldList value)
|
||||
{
|
||||
value = null!;
|
||||
if (_count == 0) return false;
|
||||
value = _buffer[_head]!;
|
||||
DropHead();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
// The SDK's queue disposes nothing; the queue stays usable, as the SDK's does.
|
||||
}
|
||||
|
||||
private int Slot(int index)
|
||||
{
|
||||
var slot = _head + index;
|
||||
return slot >= _buffer.Length ? slot - _buffer.Length : slot;
|
||||
}
|
||||
|
||||
private void DropHead()
|
||||
{
|
||||
_buffer[_head] = null;
|
||||
_head = _head + 1 == _buffer.Length ? 0 : _head + 1;
|
||||
_count--;
|
||||
if (_count == 0) _head = 0;
|
||||
}
|
||||
|
||||
private void Resize(int capacity)
|
||||
{
|
||||
var next = new EventFieldList?[Math.Max(capacity, _count)];
|
||||
for (var i = 0; i < _count; i++) next[i] = _buffer[Slot(i)];
|
||||
_buffer = next;
|
||||
_head = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The server's <see cref="IMonitoredItemQueueFactory"/> (Gitea #870): event queues are
|
||||
/// <see cref="RingEventMonitoredItemQueue"/>; data-change queues, durable requests and disposal go to the SDK's own
|
||||
/// factory unchanged.
|
||||
/// </summary>
|
||||
public sealed class RingEventQueueFactory(IMonitoredItemQueueFactory inner) : IMonitoredItemQueueFactory
|
||||
{
|
||||
private readonly IMonitoredItemQueueFactory _inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool SupportsDurableQueues => _inner.SupportsDurableQueues;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDataChangeMonitoredItemQueue CreateDataChangeQueue(bool isDurable, uint monitoredItemId) =>
|
||||
_inner.CreateDataChangeQueue(isDurable, monitoredItemId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEventMonitoredItemQueue CreateEventQueue(bool isDurable, uint monitoredItemId) =>
|
||||
isDurable ? _inner.CreateEventQueue(isDurable, monitoredItemId) : new RingEventMonitoredItemQueue(monitoredItemId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _inner.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// #866 review: the gateway fills <c>Statuses</c> on every data change, and the pump used the status row
|
||||
/// whenever one was present, so the item's OPC DA quality was never read. An off-scan / Bad / Uncertain
|
||||
/// attribute carrying an OK status row was published Good. A failure status row still wins.
|
||||
/// </summary>
|
||||
public sealed class EventPumpQualityTests
|
||||
{
|
||||
private const int WaitMs = 10_000;
|
||||
|
||||
private static MxStatusProxy Ok => new() { Success = 1, Category = MxStatusCategory.Ok };
|
||||
|
||||
private sealed class ChannelSubscriber : IGalaxySubscriber
|
||||
{
|
||||
private readonly Channel<MxEvent> _events = Channel.CreateUnbounded<MxEvent>();
|
||||
|
||||
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
||||
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
|
||||
|
||||
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync([EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var ev in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
yield return ev;
|
||||
}
|
||||
|
||||
public ValueTask EmitAsync(int handle, int quality, MxStatusProxy status)
|
||||
{
|
||||
var ev = new MxEvent
|
||||
{
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
ItemHandle = handle,
|
||||
Value = new MxValue { DoubleValue = 1 },
|
||||
Quality = quality,
|
||||
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
|
||||
};
|
||||
ev.Statuses.Add(status);
|
||||
return _events.Writer.WriteAsync(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<uint> PublishedStatusAsync(int quality, MxStatusProxy status)
|
||||
{
|
||||
var subscriber = new ChannelSubscriber();
|
||||
var registry = new SubscriptionRegistry();
|
||||
registry.Register(1, [new TagBinding("Tank01.Level", 7)]);
|
||||
var delivered = new TaskCompletionSource<DataChangeEventArgs>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
await using var pump = new EventPump(subscriber, registry, channelCapacity: 4, clientName: "Quality");
|
||||
pump.OnDataChange += (_, e) => delivered.TrySetResult(e);
|
||||
pump.Start();
|
||||
|
||||
await subscriber.EmitAsync(7, quality, status);
|
||||
(await Task.WhenAny(delivered.Task, Task.Delay(WaitMs, TestContext.Current.CancellationToken))).ShouldBe(delivered.Task);
|
||||
return (await delivered.Task).Snapshot.StatusCode;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(192, 0x00000000u)] // Good
|
||||
[InlineData(24, 0x808D0000u)] // Bad OutOfService (off scan)
|
||||
[InlineData(8, 0x808A0000u)] // Bad NotConnected
|
||||
[InlineData(68, 0x40900000u)] // Uncertain LastUsableValue
|
||||
public async Task An_ok_status_row_is_graded_by_the_item_quality(int quality, uint expected)
|
||||
=> (await PublishedStatusAsync(quality, Ok)).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public async Task A_failure_status_row_wins_over_a_good_quality()
|
||||
{
|
||||
var failure = new MxStatusProxy { Success = 0, Category = MxStatusCategory.CommunicationError, Detail = 2 };
|
||||
(await PublishedStatusAsync(192, failure)).ShouldBe(StatusCodeMap.BadCommunicationError);
|
||||
}
|
||||
}
|
||||
@@ -84,20 +84,74 @@ public sealed class StatusCodeMapTests
|
||||
StatusCodeMap.FromMxStatus(s).ShouldNotBe(StatusCodeMap.Good);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that known detail codes map to their specific status codes.</summary>
|
||||
/// <summary>
|
||||
/// #866: a low detail is an MxStatusDetail code, not an OPC DA quality byte. Detail 8 is
|
||||
/// MX_E_InvalidObjectId — it used to surface as BadNotConnected (quality byte 8).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromMxStatus_SuccessZeroAndCategoryNotOk_DetailKnown_MapsToSpecificCode()
|
||||
{
|
||||
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 8 /* Bad_NotConnected */ };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadNotConnected);
|
||||
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 8 };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadConfigurationError);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that non-zero DetectedBy maps to communication error when detail is zero.</summary>
|
||||
/// <summary>
|
||||
/// #866 live capture (VD03 2026-11-11): reading <c>JoePlatform.ScanState</c> on a deployed but
|
||||
/// not-running platform returns a failure row with detail 2 = MX_E_PlatformCommunicationError,
|
||||
/// which logged "Unrecognised MXAccess quality byte 0x02" and published generic Bad.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromMxStatus_SuccessZero_DetailZero_DetectedByNonZero_IsCommunicationError()
|
||||
public void FromMxStatus_PlatformCommunicationError_IsCommunicationError_AndDoesNotLogAsQuality()
|
||||
{
|
||||
var s = new MxStatusProxy { Success = 0, Detail = 0, RawDetectedBy = 3 };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadCommunicationError);
|
||||
var logger = new CapturingLogger();
|
||||
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.CommunicationError, Detail = 2 };
|
||||
StatusCodeMap.FromMxStatus(s, logger).ShouldBe(StatusCodeMap.BadCommunicationError);
|
||||
logger.Entries.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Low MxStatusDetail codes map per the gateway.md vocabulary regardless of category.</summary>
|
||||
[Theory]
|
||||
[InlineData(1, 0x800A0000u)] // MX_E_RequestTimedOut → BadTimeout
|
||||
[InlineData(2, 0x80050000u)] // MX_E_PlatformCommunicationError → BadCommunicationError
|
||||
[InlineData(5, 0x80050000u)] // MX_E_EngineCommunicationError → BadCommunicationError
|
||||
[InlineData(3, 0x80890000u)] // MX_E_InvalidPlatformId → BadConfigurationError
|
||||
[InlineData(4, 0x80890000u)] // MX_E_InvalidEngineId (was BadConfigurationError by quality-byte accident)
|
||||
[InlineData(6, 0x80890000u)] // MX_E_InvalidReference
|
||||
[InlineData(12, 0x80000000u)] // MX_E_ResolvingObject → category (was BadDeviceFailure)
|
||||
[InlineData(13, 0x80740000u)] // MX_E_WrongDataType → BadTypeMismatch
|
||||
[InlineData(16, 0x80000000u)] // MX_E_IndexOutOfOrder → category (was BadSensorFailure)
|
||||
[InlineData(19, 0x80740000u)] // MX_E_UnableToConvertString → BadTypeMismatch
|
||||
[InlineData(20, 0x803C0000u)] // MX_E_Overflow → BadOutOfRange (was BadCommunicationError)
|
||||
[InlineData(24, 0x80000000u)] // MX_E_LmxInvalidCommand → category (was BadOutOfService)
|
||||
[InlineData(32, 0x80000000u)] // not a vocabulary code → category (was BadWaitingForInitialData)
|
||||
public void FromMxStatus_LowDetailCodes_MapByVocabulary(int detail, uint expected)
|
||||
{
|
||||
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = detail };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>The category fallback logs once per (detail, category), not on every data change.</summary>
|
||||
[Fact]
|
||||
public void FromMxStatus_CategoryFallback_LogsOncePerDetailAndCategory()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.SoftwareError, Detail = 23 /* MX_E_LmxVersionMismatch */ };
|
||||
for (var i = 0; i < 5; i++) StatusCodeMap.FromMxStatus(s, logger).ShouldBe(StatusCodeMap.Bad);
|
||||
logger.Entries.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #872: detected_by names an MXAccess layer (3 = responding NMX), not a transport failure, so a detail-0
|
||||
/// failure row routes on its category — a communication-category row is still BadCommunicationError.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(MxStatusCategory.Unspecified, 0x80000000u)]
|
||||
[InlineData(MxStatusCategory.CommunicationError, 0x80050000u)]
|
||||
[InlineData(MxStatusCategory.ConfigurationError, 0x80890000u)]
|
||||
public void FromMxStatus_SuccessZero_DetailZero_RoutesByCategory_NotDetectedBy(MxStatusCategory category, uint expected)
|
||||
{
|
||||
var s = new MxStatusProxy { Success = 0, Detail = 0, RawDetectedBy = 3, Category = category };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that all-zero status maps to Bad status code.</summary>
|
||||
@@ -155,9 +209,8 @@ public sealed class StatusCodeMapTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invariant: a row that is not IsSuccess() must NEVER map to Good — a Good-band quality
|
||||
/// byte (e.g. 216 = GoodLocalOverride) on a failure row is contradictory and falls back to
|
||||
/// the category route. Uncertain-band substatuses remain allowed (uncertain is not Good).
|
||||
/// Invariant: a row that is not IsSuccess() must NEVER map to Good — a detail that happens to sit in
|
||||
/// the OPC DA Good band (216) is not a quality byte and routes on the category.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromMxStatus_FailureRow_GoodBandQualityByte_NeverMapsGood()
|
||||
@@ -166,12 +219,12 @@ public sealed class StatusCodeMapTests
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
/// <summary>An Uncertain-band quality substatus on a failure row still maps to its specific Uncertain code.</summary>
|
||||
/// <summary>A failure row is Bad: a detail in the OPC DA Uncertain band (68) is not a quality byte (#866).</summary>
|
||||
[Fact]
|
||||
public void FromMxStatus_FailureRow_UncertainBandQualityByte_KeepsUncertainCode()
|
||||
public void FromMxStatus_FailureRow_UncertainBandDetail_IsBad()
|
||||
{
|
||||
var s = new MxStatusProxy { Success = 0, Category = MxStatusCategory.OperationalError, Detail = 68 };
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.UncertainLastUsableValue);
|
||||
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that top two bits of status codes follow OPC UA convention.</summary>
|
||||
|
||||
@@ -103,7 +103,9 @@ public sealed class SqlTestDatabaseReaperTests
|
||||
// what bounds the character set of every candidate: without it a database named
|
||||
// "<prefix><aged timestamp>_" + arbitrary text would be fed to the drop statement, and before the
|
||||
// statement was parameterised that was a second-order injection running as sa.
|
||||
var notAGuid = $"{AdminUiSqlServer.TestDatabasePrefix}20250101000000_notaguid";
|
||||
// Unique per run: the SQL Server is shared by every checkout and CI, and a fixed name collided with a
|
||||
// concurrent run's create / finally-drop. Digits only after "notaguid_", so it can never parse as a GUID.
|
||||
var notAGuid = $"{AdminUiSqlServer.TestDatabasePrefix}20250101000000_notaguid_{Environment.ProcessId}_{Random.Shared.Next(1_000_000)}";
|
||||
|
||||
CreateDatabase(master, notAGuid);
|
||||
|
||||
|
||||
+7
-1
@@ -232,7 +232,13 @@ public sealed class MeshPskProviderRefreshTests
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
public ManualResetEventSlim Entered { get; } = new();
|
||||
|
||||
public void Hold() => _gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
// Reset Entered too: the priming refresh already set it, so without the reset Wait() returned before the
|
||||
// held call had even reached the store and the call-count snapshot raced its increment (hosted-CI flake).
|
||||
public void Hold()
|
||||
{
|
||||
Entered.Reset();
|
||||
_gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
public void Release() => _gate?.TrySetResult();
|
||||
|
||||
public async Task<string?> GetAsync(SecretName name, CancellationToken ct)
|
||||
|
||||
+37
-2
@@ -35,6 +35,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
/// compiles against the pre-#855 tree for a before/after comparison.</para>
|
||||
/// </remarks>
|
||||
[Trait("Category", "Perf")]
|
||||
// Shares a collection with the other user of OtOpcUaSdkServer.RingEventQueues (a process-wide switch the perf
|
||||
// harness flips), so the wiring assertion never observes it switched off (#870 review).
|
||||
[Collection(RingEventQueueSwitchCollection.Name)]
|
||||
public sealed class FolderEventDeliveryPerfTests
|
||||
{
|
||||
private const string OptInVariable = "OTOPCUA_EVENT_PERF";
|
||||
@@ -100,8 +103,40 @@ public sealed class FolderEventDeliveryPerfTests
|
||||
public Task Stalled_items_queue_size_sensitivity(uint queueSize) =>
|
||||
RunAsync(50, acl: false, stalled: true, serverObjectProbe: false, queueSize);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #870 A/B in one process (same machine load): N stalled folder items at a full queue, with the server's
|
||||
/// ring-buffer event queues vs the SDK's own <c>EventMonitoredItemQueue</c> (<c>List.RemoveAt(0)</c> per discard).
|
||||
/// Each round runs both arms, alternating which goes first, so a load spike lands on both.
|
||||
/// <c>OTOPCUA_EVENT_PERF_ROUNDS</c> sets the rounds (default 2).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(50, 1000u, false)]
|
||||
[InlineData(200, 1000u, false)]
|
||||
[InlineData(200, 1000u, true)]
|
||||
[InlineData(50, 10000u, false)]
|
||||
public async Task Ring_vs_sdk_event_queue(int n, uint queueSize, bool acl)
|
||||
{
|
||||
RequireOptIn();
|
||||
var rounds = int.TryParse(Environment.GetEnvironmentVariable("OTOPCUA_EVENT_PERF_ROUNDS"), out var r) && r > 0 ? r : 2;
|
||||
try
|
||||
{
|
||||
for (var round = 0; round < rounds; round++)
|
||||
{
|
||||
foreach (var ring in round % 2 == 0 ? new[] { false, true } : [true, false])
|
||||
{
|
||||
OtOpcUaSdkServer.RingEventQueues = ring;
|
||||
await RunAsync(n, acl, stalled: true, serverObjectProbe: false, queueSize, ring ? "ring" : "sdk ");
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
OtOpcUaSdkServer.RingEventQueues = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RunAsync(
|
||||
int n, bool acl, bool stalled, bool serverObjectProbe, uint queueSize = DefaultQueueSize)
|
||||
int n, bool acl, bool stalled, bool serverObjectProbe, uint queueSize = DefaultQueueSize, string? queueKind = null)
|
||||
{
|
||||
RequireOptIn();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
@@ -195,7 +230,7 @@ public sealed class FolderEventDeliveryPerfTests
|
||||
var deferredPeak = rig.Nm.DeferredEventReports;
|
||||
var row = new StringBuilder();
|
||||
row.Append(CultureInfo.InvariantCulture,
|
||||
$"{(serverObjectProbe ? "i=2253" : "folder"),-6} N={n,-3} acl={(acl ? "on " : "off")} {(stalled ? "stalled " : "publishing")} q={queueSize,-5} | ");
|
||||
$"{(serverObjectProbe ? "i=2253" : "folder"),-6} N={n,-3} acl={(acl ? "on " : "off")} {(stalled ? "stalled " : "publishing")} q={queueSize,-5} {(queueKind is null ? "" : queueKind + " ")}| ");
|
||||
row.Append(CultureInfo.InvariantCulture, $"lock hold us p50 {Pct(holds, 50),8:F1} p90 {Pct(holds, 90),8:F1} p99 {Pct(holds, 99),8:F1} max {holds.Max(),9:F1} mean {holds.Average(),8:F1}");
|
||||
row.Append(CultureInfo.InvariantCulture, $" | per-item mean {(n > 0 ? holds.Average() / n : 0),6:F2} us");
|
||||
if (expectFolder)
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #870: the running server's monitored-item queue factory is <see cref="RingEventQueueFactory"/> (every event
|
||||
/// item's queue is a <see cref="RingEventMonitoredItemQueue"/>; the SDK's <see cref="EventQueueHandler"/> always asks
|
||||
/// the server's factory), and data-change queues are still the SDK's own.
|
||||
/// </summary>
|
||||
// Shares a collection with the other user of OtOpcUaSdkServer.RingEventQueues (a process-wide switch the perf
|
||||
// harness flips), so the wiring assertion never observes it switched off (#870 review).
|
||||
[Collection(RingEventQueueSwitchCollection.Name)]
|
||||
public sealed class RingEventQueueWiringTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task The_server_creates_ring_event_queues_and_sdk_data_change_queues()
|
||||
{
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await using var rig = await ServerEventRig.StartAsync("RingQueue870", encrypted: false, ct);
|
||||
|
||||
var factory = rig.Server.CurrentInstance.MonitoredItemQueueFactory.ShouldBeOfType<RingEventQueueFactory>();
|
||||
factory.SupportsDurableQueues.ShouldBeFalse();
|
||||
factory.CreateEventQueue(false, 1).ShouldBeOfType<RingEventMonitoredItemQueue>();
|
||||
factory.CreateDataChangeQueue(false, 2).ShouldBeOfType<DataChangeMonitoredItemQueue>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serialises the tests that read or flip <c>OtOpcUaSdkServer.RingEventQueues</c>.</summary>
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class RingEventQueueSwitchCollection
|
||||
{
|
||||
/// <summary>The collection name.</summary>
|
||||
public const string Name = "RingEventQueueSwitch";
|
||||
}
|
||||
@@ -162,7 +162,9 @@ public sealed class FailedCredentialLockoutTests
|
||||
lockout.Complete(Client, succeeded: false);
|
||||
}, Ct)).ToArray();
|
||||
|
||||
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 15);
|
||||
// Wait for the slot holders' increments too: the 15 waiters can queue before a slot holder's thread has run
|
||||
// the line after BeginAsync, which read authenticated == 0 on the 2-core hosted runner.
|
||||
await WaitUntilAsync(() => lockout.WaitingCount(Client) == 15 && Volatile.Read(ref authenticated) == 5);
|
||||
authenticated.ShouldBe(5);
|
||||
release.SetResult();
|
||||
await Task.WhenAll(attempts);
|
||||
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #870: <see cref="RingEventMonitoredItemQueue"/> must be observably identical to the SDK's
|
||||
/// <see cref="EventMonitoredItemQueue"/>. Every test drives the SDK queue and the ring with the same operations and
|
||||
/// compares every result — directly at the queue, and through the SDK's own <see cref="EventQueueHandler"/> (which owns
|
||||
/// DiscardOldest, the overflow-event positions and the discarded/overflow counters) built over each factory.
|
||||
/// </summary>
|
||||
public sealed class RingEventMonitoredItemQueueParityTests
|
||||
{
|
||||
private static readonly ITelemetryContext Telemetry = DefaultTelemetry.Create(_ => { });
|
||||
|
||||
private sealed class Target : IFilterTarget
|
||||
{
|
||||
public bool IsTypeOf(IFilterContext context, NodeId typeDefinitionId) => false;
|
||||
|
||||
public Variant GetAttributeValue(IFilterContext context, NodeId typeDefinitionId,
|
||||
ArrayOf<QualifiedName> relativePath, uint attributeId, NumericRange indexRange) => Variant.Null;
|
||||
}
|
||||
|
||||
private static readonly Target[] Targets = Enumerable.Range(0, 6).Select(_ => new Target()).ToArray();
|
||||
|
||||
private static EventFieldList NewEvent(Random random) => new() { Handle = Targets[random.Next(Targets.Length)] };
|
||||
|
||||
/// <summary>Runs <paramref name="action"/> on both, asserting the same outcome (value or exception type + status).</summary>
|
||||
private static T Both<T>(Func<IEventMonitoredItemQueue, T> action, IEventMonitoredItemQueue sdk,
|
||||
IEventMonitoredItemQueue ring, string step)
|
||||
{
|
||||
Exception? sdkError = null, ringError = null;
|
||||
T sdkResult = default!, ringResult = default!;
|
||||
try { sdkResult = action(sdk); } catch (Exception e) { sdkError = e; }
|
||||
try { ringResult = action(ring); } catch (Exception e) { ringError = e; }
|
||||
|
||||
(ringError?.GetType()).ShouldBe(sdkError?.GetType(), step);
|
||||
if (sdkError is ServiceResultException s)
|
||||
((ServiceResultException)ringError!).StatusCode.ShouldBe(s.StatusCode, step);
|
||||
if (sdkError is null) ringResult.ShouldBe(sdkResult, step);
|
||||
ring.ItemsInQueue.ShouldBe(sdk.ItemsInQueue, step);
|
||||
ring.QueueSize.ShouldBe(sdk.QueueSize, step);
|
||||
return sdkResult;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initial_state_matches()
|
||||
{
|
||||
using var sdk = new EventMonitoredItemQueue(false, 7, Telemetry);
|
||||
using var ring = new RingEventMonitoredItemQueue(7);
|
||||
ring.MonitoredItemId.ShouldBe(sdk.MonitoredItemId);
|
||||
ring.IsDurable.ShouldBe(sdk.IsDurable);
|
||||
ring.QueueSize.ShouldBe(sdk.QueueSize);
|
||||
ring.ItemsInQueue.ShouldBe(sdk.ItemsInQueue);
|
||||
ring.Dequeue(out var value).ShouldBeFalse();
|
||||
value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enqueue_at_queue_size_zero_throws_the_same_BadInternalError()
|
||||
{
|
||||
var sdk = new EventMonitoredItemQueue(false, 1, Telemetry);
|
||||
var ring = new RingEventMonitoredItemQueue(1);
|
||||
Both(q => { q.Enqueue(new EventFieldList()); return 0; }, sdk, ring, "enqueue at 0");
|
||||
Should.Throw<ServiceResultException>(() => ring.Enqueue(new EventFieldList())).StatusCode
|
||||
.ShouldBe(StatusCodes.BadInternalError);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void Shrink_keeps_newest_or_oldest_and_dequeue_order_is_fifo(bool discardOldest)
|
||||
{
|
||||
var sdk = new EventMonitoredItemQueue(false, 1, Telemetry);
|
||||
var ring = new RingEventMonitoredItemQueue(1);
|
||||
var events = Enumerable.Range(0, 10).Select(_ => new EventFieldList()).ToArray();
|
||||
sdk.SetQueueSize(10, true);
|
||||
ring.SetQueueSize(10, true);
|
||||
// Wrap the ring first so the shrink crosses the buffer end.
|
||||
for (var i = 0; i < 6; i++) { sdk.Enqueue(new EventFieldList()); ring.Enqueue(new EventFieldList()); }
|
||||
for (var i = 0; i < 6; i++) { sdk.Dequeue(out _); ring.Dequeue(out _); }
|
||||
foreach (var e in events) { sdk.Enqueue(e); ring.Enqueue(e); }
|
||||
|
||||
sdk.SetQueueSize(4, discardOldest);
|
||||
ring.SetQueueSize(4, discardOldest);
|
||||
|
||||
var expected = discardOldest ? events[6..] : events[..4];
|
||||
foreach (var e in expected)
|
||||
{
|
||||
sdk.Dequeue(out var a).ShouldBeTrue();
|
||||
ring.Dequeue(out var b).ShouldBeTrue();
|
||||
a.ShouldBeSameAs(e);
|
||||
b.ShouldBeSameAs(e);
|
||||
}
|
||||
|
||||
ring.Dequeue(out _).ShouldBeFalse();
|
||||
sdk.Dequeue(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enqueue_at_a_full_queue_drops_the_oldest()
|
||||
{
|
||||
var ring = new RingEventMonitoredItemQueue(1);
|
||||
var sdk = new EventMonitoredItemQueue(false, 1, Telemetry);
|
||||
ring.SetQueueSize(3, false);
|
||||
sdk.SetQueueSize(3, false);
|
||||
var events = Enumerable.Range(0, 5).Select(_ => new EventFieldList()).ToArray();
|
||||
foreach (var e in events) { ring.Enqueue(e); sdk.Enqueue(e); }
|
||||
foreach (var e in events[2..])
|
||||
{
|
||||
ring.Dequeue(out var b).ShouldBeTrue();
|
||||
sdk.Dequeue(out var a).ShouldBeTrue();
|
||||
b.ShouldBeSameAs(e);
|
||||
a.ShouldBeSameAs(e);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Duplicate_check_covers_exactly_the_newest_1000_entries()
|
||||
{
|
||||
var sdk = new EventMonitoredItemQueue(false, 1, Telemetry);
|
||||
var ring = new RingEventMonitoredItemQueue(1);
|
||||
sdk.SetQueueSize(3000, true);
|
||||
ring.SetQueueSize(3000, true);
|
||||
var old = new Target();
|
||||
var edge = new Target();
|
||||
void Add(EventFieldList e) { sdk.Enqueue(e); ring.Enqueue(e); }
|
||||
Add(new EventFieldList { Handle = old });
|
||||
Add(new EventFieldList { Handle = edge });
|
||||
for (var i = 0; i < 999; i++) Add(new EventFieldList { Handle = Targets[0] });
|
||||
Add(null!); // a null entry is skipped, as the SDK skips it
|
||||
|
||||
// 1002 entries: `old` is the 1002nd newest, `edge` the 1001st — both outside the 1000-entry window.
|
||||
Both(q => q.IsEventContainedInQueue(old), sdk, ring, "old").ShouldBeFalse();
|
||||
Both(q => q.IsEventContainedInQueue(edge), sdk, ring, "edge").ShouldBeFalse();
|
||||
sdk.Dequeue(out _); ring.Dequeue(out _);
|
||||
sdk.Dequeue(out _); ring.Dequeue(out _);
|
||||
Add(new EventFieldList { Handle = edge });
|
||||
Both(q => q.IsEventContainedInQueue(edge), sdk, ring, "newest").ShouldBeTrue();
|
||||
Both(q => q.IsEventContainedInQueue(Targets[0]), sdk, ring, "middle").ShouldBeTrue();
|
||||
Both(q => q.IsEventContainedInQueue(Targets[1]), sdk, ring, "absent").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Upper boundary of the window: the entry exactly 1000th from newest is still found; 1001st is not.</summary>
|
||||
[Fact]
|
||||
public void Duplicate_check_includes_the_1000th_newest_entry()
|
||||
{
|
||||
var sdk = new EventMonitoredItemQueue(false, 1, Telemetry);
|
||||
var ring = new RingEventMonitoredItemQueue(1);
|
||||
sdk.SetQueueSize(3000, true);
|
||||
ring.SetQueueSize(3000, true);
|
||||
var outside = new Target();
|
||||
var boundary = new Target();
|
||||
void Add(EventFieldList e) { sdk.Enqueue(e); ring.Enqueue(e); }
|
||||
Add(new EventFieldList { Handle = outside });
|
||||
Add(new EventFieldList { Handle = boundary });
|
||||
for (var i = 0; i < 999; i++) Add(new EventFieldList { Handle = Targets[0] });
|
||||
|
||||
// 1001 entries: `boundary` is the 1000th newest (inside), `outside` the 1001st.
|
||||
Both(q => q.IsEventContainedInQueue(boundary), sdk, ring, "boundary").ShouldBeTrue();
|
||||
Both(q => q.IsEventContainedInQueue(outside), sdk, ring, "outside").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Random interleavings of every queue operation, at small sizes (wrap-around, grow, shrink, size 0).</summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(Seeds))]
|
||||
public void Random_operation_sequences_match_the_sdk_queue(int seed)
|
||||
{
|
||||
var random = new Random(seed);
|
||||
var sdk = new EventMonitoredItemQueue(false, 3, Telemetry);
|
||||
var ring = new RingEventMonitoredItemQueue(3);
|
||||
for (var step = 0; step < 2000; step++)
|
||||
{
|
||||
var label = $"seed {seed} step {step}";
|
||||
switch (random.Next(10))
|
||||
{
|
||||
case < 5:
|
||||
var e = random.Next(50) == 0 ? null! : NewEvent(random);
|
||||
Both(q => { q.Enqueue(e); return 0; }, sdk, ring, label);
|
||||
break;
|
||||
case < 8:
|
||||
var (sdkHad, sdkValue) = (sdk.Dequeue(out var a), a);
|
||||
var (ringHad, ringValue) = (ring.Dequeue(out var b), b);
|
||||
ringHad.ShouldBe(sdkHad, label);
|
||||
ringValue.ShouldBeSameAs(sdkValue, label);
|
||||
break;
|
||||
case 8:
|
||||
var size = (uint)(random.Next(8) == 0 ? 0 : random.Next(1, 40));
|
||||
var discardOldest = random.Next(2) == 0;
|
||||
Both(q => { q.SetQueueSize(size, discardOldest); return 0; }, sdk, ring, label);
|
||||
break;
|
||||
default:
|
||||
var t = Targets[random.Next(Targets.Length)];
|
||||
Both(q => q.IsEventContainedInQueue(t), sdk, ring, label);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Drain: the same remaining contents in the same order.
|
||||
while (sdk.Dequeue(out var a))
|
||||
{
|
||||
ring.Dequeue(out var b).ShouldBeTrue();
|
||||
b.ShouldBeSameAs(a);
|
||||
}
|
||||
|
||||
ring.Dequeue(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
public static TheoryData<int> Seeds() => new(Enumerable.Range(1, 40));
|
||||
|
||||
/// <summary>
|
||||
/// The SDK's <see cref="EventQueueHandler"/> over the SDK factory vs over <see cref="RingEventQueueFactory"/>:
|
||||
/// QueueEvent (incl. the DiscardOldest=false refusal), SetQueueOverflowIfFull, SetQueueSize with DiscardOldest
|
||||
/// flips, Publish with small batches (dequeue mid-stream) — the published events, the overflow-event-due flags,
|
||||
/// the Overflow property and the discarded/overflow callbacks must all agree.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(Seeds))]
|
||||
public void The_sdk_event_queue_handler_behaves_identically_over_either_queue(int seed)
|
||||
{
|
||||
var random = new Random(seed);
|
||||
var counts = new int[4]; // sdk discarded, sdk overflow, ring discarded, ring overflow
|
||||
using var sdkFactory = new MonitoredItemQueueFactory(Telemetry);
|
||||
using var ringFactory = new RingEventQueueFactory(new MonitoredItemQueueFactory(Telemetry));
|
||||
using var sdk = new EventQueueHandler(false, sdkFactory, 9, Telemetry, () => counts[0]++, () => counts[1]++);
|
||||
using var ring = new EventQueueHandler(false, ringFactory, 9, Telemetry, () => counts[2]++, () => counts[3]++);
|
||||
var context = new OperationContext(new RequestHeader(), null, RequestType.Publish, RequestLifetime.None);
|
||||
|
||||
var initialOldest = random.Next(2) == 0;
|
||||
sdk.SetQueueSize(10, initialOldest);
|
||||
ring.SetQueueSize(10, initialOldest);
|
||||
|
||||
for (var step = 0; step < 1500; step++)
|
||||
{
|
||||
var label = $"seed {seed} step {step}";
|
||||
switch (random.Next(12))
|
||||
{
|
||||
case < 6:
|
||||
var e = NewEvent(random);
|
||||
Exception? a = null, b = null;
|
||||
try { sdk.QueueEvent(e); } catch (Exception x) { a = x; }
|
||||
try { ring.QueueEvent(e); } catch (Exception x) { b = x; }
|
||||
(b?.GetType()).ShouldBe(a?.GetType(), label);
|
||||
break;
|
||||
case 6:
|
||||
ring.SetQueueOverflowIfFull().ShouldBe(sdk.SetQueueOverflowIfFull(), label);
|
||||
break;
|
||||
case 7:
|
||||
var size = (uint)random.Next(1, 25);
|
||||
var discardOldest = random.Next(2) == 0;
|
||||
sdk.SetQueueSize(size, discardOldest);
|
||||
ring.SetQueueSize(size, discardOldest);
|
||||
break;
|
||||
case < 10:
|
||||
var max = (uint)random.Next(1, 6);
|
||||
var sdkOut = new Queue<EventFieldList>();
|
||||
var ringOut = new Queue<EventFieldList>();
|
||||
var sdkN = sdk.Publish(context, sdkOut, max);
|
||||
var ringN = ring.Publish(context, ringOut, max);
|
||||
ringN.ShouldBe(sdkN, label);
|
||||
ringOut.Count.ShouldBe(sdkOut.Count, label);
|
||||
while (sdkOut.Count > 0) ringOut.Dequeue().ShouldBeSameAs(sdkOut.Dequeue(), label);
|
||||
break;
|
||||
default:
|
||||
var t = Targets[random.Next(Targets.Length)];
|
||||
ring.IsEventContainedInQueue(t).ShouldBe(sdk.IsEventContainedInQueue(t), label);
|
||||
break;
|
||||
}
|
||||
|
||||
ring.ItemsInQueue.ShouldBe(sdk.ItemsInQueue, label);
|
||||
ring.Overflow.ShouldBe(sdk.Overflow, label);
|
||||
counts[2].ShouldBe(counts[0], label);
|
||||
counts[3].ShouldBe(counts[1], label);
|
||||
}
|
||||
|
||||
// Positive control: the sequence really overflowed (discards and overflow events happened).
|
||||
counts[0].ShouldBeGreaterThan(0);
|
||||
counts[1].ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_supplies_ring_event_queues_and_delegates_everything_else()
|
||||
{
|
||||
using var factory = new RingEventQueueFactory(new MonitoredItemQueueFactory(Telemetry));
|
||||
factory.SupportsDurableQueues.ShouldBeFalse();
|
||||
factory.CreateEventQueue(false, 4).ShouldBeOfType<RingEventMonitoredItemQueue>().MonitoredItemId.ShouldBe(4u);
|
||||
factory.CreateDataChangeQueue(false, 5).ShouldBeOfType<DataChangeMonitoredItemQueue>();
|
||||
// A durable request reaches the SDK factory, which refuses it as before.
|
||||
Should.Throw<ArgumentException>(() => factory.CreateEventQueue(true, 6));
|
||||
Should.Throw<ArgumentException>(() => new MonitoredItemQueueFactory(Telemetry).CreateEventQueue(true, 6));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Buffer_grows_lazily_and_is_released_on_shrink()
|
||||
{
|
||||
var ring = new RingEventMonitoredItemQueue(1);
|
||||
ring.SetQueueSize(10000, true);
|
||||
ring.Capacity.ShouldBe(0);
|
||||
for (var i = 0; i < 20000; i++) ring.Enqueue(new EventFieldList());
|
||||
ring.Capacity.ShouldBe(10000);
|
||||
ring.ItemsInQueue.ShouldBe(10000);
|
||||
ring.SetQueueSize(10, true);
|
||||
ring.ItemsInQueue.ShouldBe(10);
|
||||
ring.Capacity.ShouldBeLessThanOrEqualTo(20);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user