Merge #870 ring-buffer event monitored-item queue

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 15:20:31 -05:00
6 changed files with 585 additions and 3 deletions
@@ -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();
}