perf(opcua): #870 ring-buffer event monitored-item queue
Since #855 condition events are queued to folder event items under the node manager Lock. At a full DiscardOldest queue the SDK's EventQueueHandler dequeues the head of EventMonitoredItemQueue, a List.RemoveAt(0) shift of the whole queue (~1 us/item at QueueSize 1000, ~10 us at 10000). OtOpcUaSdkServer.CreateMonitoredItemQueueFactory now wraps the SDK factory in RingEventQueueFactory: event queues are RingEventMonitoredItemQueue (O(1) discard), data-change queues, durable requests and disposal go to the SDK factory unchanged. A factory set through StandardServer.MonitoredItemQueueFactory is used as-is. The SDK queue is storage only (decompiled 2.0.1): DiscardOldest, overflow-event positions and discarded/overflow counters live in EventQueueHandler, which is unchanged. The ring reproduces QueueSize 0 initial + BadInternalError enqueue, drop-oldest at Count == QueueSize, FIFO dequeue, SetQueueSize keep-newest / keep-oldest, the newest-1000 duplicate window via IsEventRaisedFor, no-op Dispose. RingEventMonitoredItemQueueParityTests drive both queues, and the SDK EventQueueHandler over both factories, with the same random sequences. FolderEventDeliveryPerfTests gains Ring_vs_sdk_event_queue (A/B in one process via the internal OtOpcUaSdkServer.RingEventQueues switch). Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user