mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-27 13:10:37 +08:00
Server: cheaper per-item event delivery (O(1) dequeue, duplicate index, filter context reuse, no per-read timestamps) (#4837)
# Description Low-risk speedups in the server event delivery path. With many event monitored items on one notifier (750 on the Server object in the `events` harness scenario), a single `MonitoredNode2` consumer thread delivers every event to every item and is 100 % busy, which caps the server at ~770 events/s. This PR makes the per-item work cheaper. There are no public API or wire changes. Each commit stands alone: 1. **Server: dequeue events in O(1).** `EventMonitoredItemQueue.Dequeue` used `List.RemoveAt(0)`, which shifted every queued event. It now advances a head index, compacts the list once the dequeued slots are half of it, and clears it when it runs empty. Discard oldest/newest, the `SetQueueSize` truncation and the newest-1000 duplicate range are unchanged. The mirroring redundancy queue snapshots only the queued range. 2. **Server: reuse the event filter context of a monitored item.** `QueueEvent` created a `FilterContext` per event and item. The item now keeps it until the session replaces its `PreferredLocales`. `GetEventFields` writes into a `Variant[]` instead of a `List` that was then copied, and translates with the context's locales instead of looking up the session per LocalizedText field. 3. **Server: check event duplicates against an index of the newest entries.** `IsEventContainedInQueue` loaded the handle of up to 1000 queued entries per item and event. A reference-keyed count of the event instances in the newest 1000 entries, kept current by `Enqueue`/`Dequeue`/`SetQueueSize`, makes it one lookup. The checked range is the same, and wrapped filtered-retain handles count as their instance. If a derived queue changes `m_events` directly, the index is rebuilt. 4. **Core: evaluate an empty content filter without an evaluator.** `ContentFilter.Evaluate` created a `FilterEvaluator` and, through the telemetry context, a new logger for every evaluation, also for the empty where clause of most event filters (~16 % of the scenario's allocated bytes). An empty filter now passes directly, and the evaluator creates its logger only to log a failure. 5. **Core: give the fields of an initialized event a source timestamp.** `BaseEventState.Initialize` created its field variables without a timestamp, so `BaseVariableState.ReadValueAttribute` called `DateTimeUtc.Now` on every read of every field for every monitored item. The fields now carry the event time (`ReceiveTime` its own value). The read path is unchanged, so no `ReadChildAttribute`/`ReadValueAttribute` override is bypassed. ## Measurements `perf/ServerLoadHarness run --scenario events --sessions 50 --subs 15 --pub 100 --security encrypt --event-rate 0 --duration 15 --warmup 8`, with `DOTNET_gcServer=1`, `SERVER_DOTNET_gcServer=1` and `SERVER_DOTNET_TC_QuickJitForLoops=0`, `SERVER_DOTNET_TC_OnStackReplacement=0`. Each build is stacked on the previous one; values are the mean of 2 alternating rounds. Runs where processes outside the harness used more than 1.5 cores were repeated. | build | generated events/s | event→notify p50 | KB / generated event | ms CPU / generated event | |---|---|---|---|---| | master | 768 | 69.8 ms | 567 | 2.97 | | + O(1) dequeue | 791 | 68.4 ms | 566 | 2.78 | | + filter context reuse | 818 | 68.8 ms | 354 | 2.75 | | + duplicate index | 854 | 63.7 ms | 356 | 2.96 | | + empty where clause | 891 | 63.7 ms | 273 | 2.77 | | + event field timestamps | **1001** | **61.3 ms** | **272** | **2.66** | - **`--event-rate 500` (below capacity):** 3.34 → 3.03 ms CPU and 590 → 294 KB per generated event. Latency is unchanged (bound by the publishing interval). - **Default JIT, 40 s warm-up:** 804 → 1063 events/s, 568 → 268 KB, 2.69 → 2.37 ms. - **CPU traces:** the consumer thread's `ReadValueAttribute` self share drops from 24 % to 6 % of the samples and `QueueEvent` self from 14 % to 5 %. The thread is still the bottleneck, now in the generic read chain (`GetAttributeValue` → `ReadChildAttribute` → `FindChild`). The next step would be to evaluate each event once per distinct filter rather than per item, which is out of scope here. ## Notes for reviewers - `EventMonitoredItemQueue.m_events` keeps its protected `List<EventFieldList>` type, but dequeued slots now stay null at the front until compaction. The queued events are the last `ItemsInQueue` entries. Nothing in the repository reads the field outside the class except the mirroring queue, which uses the new internal `CopyQueuedEvents()`. - Event field variables created by `Initialize` now report the event time as `SourceTimestamp` instead of the time of the read. ## Related Issues - No issue. Found by the server load investigation of publish-, write- and event-heavy load. ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. (net10.0 only: Opc.Ua.Server.Tests 8990 passed, Opc.Ua.Core.Tests 6449 passed, Opc.Ua.Redundancy.Server.Tests 922 passed. In the explicit Sessions `ServerEventSubscribeLoadTest`, opc.tcp passes; https and opc.https fail with HTTP 503, the same as on master.) - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
37a2a831f3
commit
484596c2a8
@@ -44,8 +44,13 @@ namespace Opc.Ua
|
||||
EventSeverity severity,
|
||||
LocalizedText message)
|
||||
{
|
||||
m_eventId = PropertyState<ByteString>.With<VariantBuilder>(this, Uuid.NewUuid().ToByteString());
|
||||
m_eventType = PropertyState<NodeId>.With<VariantBuilder>(this, GetDefaultTypeDefinitionId(context.NamespaceUris));
|
||||
// the fields carry the event time as their source timestamp: a field without
|
||||
// one is stamped with the current time on every read, and an event's fields are
|
||||
// read once per select clause and monitored item it is delivered to.
|
||||
DateTimeUtc time = DateTimeUtc.Now;
|
||||
|
||||
m_eventId = WithTimestamp(PropertyState<ByteString>.With<VariantBuilder>(this, Uuid.NewUuid().ToByteString()), time);
|
||||
m_eventType = WithTimestamp(PropertyState<NodeId>.With<VariantBuilder>(this, GetDefaultTypeDefinitionId(context.NamespaceUris)), time);
|
||||
|
||||
TypeDefinitionId = m_eventType.Value;
|
||||
if (source != null)
|
||||
@@ -55,6 +60,7 @@ namespace Opc.Ua
|
||||
m_sourceNode = new PropertyState<NodeId>.Implementation<VariantBuilder>(this)
|
||||
{
|
||||
Value = source.NodeId,
|
||||
Timestamp = time,
|
||||
RolePermissions = source.RolePermissions,
|
||||
UserRolePermissions = source.UserRolePermissions,
|
||||
NodeId = source.NodeId
|
||||
@@ -63,14 +69,28 @@ namespace Opc.Ua
|
||||
|
||||
if (!source.BrowseName.IsNull)
|
||||
{
|
||||
m_sourceName = PropertyState<string>.With<VariantBuilder>(this, source.BrowseName.Name!); // QualifiedName.IsNull is false here, so Name is non-null
|
||||
m_sourceName = WithTimestamp(PropertyState<string>.With<VariantBuilder>(this, source.BrowseName.Name!), time); // QualifiedName.IsNull is false here, so Name is non-null
|
||||
}
|
||||
}
|
||||
|
||||
m_time = PropertyState<DateTimeUtc>.With<VariantBuilder>(this, DateTimeUtc.Now);
|
||||
m_receiveTime = PropertyState<DateTimeUtc>.With<VariantBuilder>(this, DateTimeUtc.Now);
|
||||
m_severity = PropertyState<ushort>.With<VariantBuilder>(this, (ushort)severity);
|
||||
m_message = PropertyState<LocalizedText>.With<VariantBuilder>(this, message);
|
||||
m_time = WithTimestamp(PropertyState<DateTimeUtc>.With<VariantBuilder>(this, time), time);
|
||||
DateTimeUtc receiveTime = DateTimeUtc.Now;
|
||||
m_receiveTime = WithTimestamp(PropertyState<DateTimeUtc>.With<VariantBuilder>(this, receiveTime), receiveTime);
|
||||
m_severity = WithTimestamp(PropertyState<ushort>.With<VariantBuilder>(this, (ushort)severity), time);
|
||||
m_message = WithTimestamp(PropertyState<LocalizedText>.With<VariantBuilder>(this, message), time);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the source timestamp of an event field.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the field.</typeparam>
|
||||
/// <param name="field">The field.</param>
|
||||
/// <param name="timestamp">The source timestamp.</param>
|
||||
private static T WithTimestamp<T>(T field, DateTimeUtc timestamp)
|
||||
where T : BaseVariableState
|
||||
{
|
||||
field.Timestamp = timestamp;
|
||||
return field;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ namespace Opc.Ua
|
||||
m_filter = filter;
|
||||
m_context = context;
|
||||
m_target = target;
|
||||
m_logger = context.Telemetry.CreateLogger<FilterEvaluator>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -977,7 +976,7 @@ namespace Opc.Ua
|
||||
ArgumentException or
|
||||
NullReferenceException)
|
||||
{
|
||||
m_logger.ConversionFailed(ex, targetType);
|
||||
Logger.ConversionFailed(ex, targetType);
|
||||
return default;
|
||||
}
|
||||
}
|
||||
@@ -1131,7 +1130,7 @@ namespace Opc.Ua
|
||||
ex is not OutOfMemoryException and not StackOverflowException and
|
||||
not AccessViolationException and not OperationCanceledException)
|
||||
{
|
||||
m_logger.TargetEvaluationFailed(ex);
|
||||
Logger.TargetEvaluationFailed(ex);
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
@@ -1140,10 +1139,16 @@ namespace Opc.Ua
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The logger, created when a failure is logged: a filter is evaluated for every
|
||||
/// event and monitored item, and creating a logger each time is not free.
|
||||
/// </summary>
|
||||
private ILogger Logger => m_logger ??= m_context.Telemetry.CreateLogger<FilterEvaluator>();
|
||||
|
||||
private readonly ContentFilter m_filter;
|
||||
private readonly IFilterContext m_context;
|
||||
private readonly IFilterTarget m_target;
|
||||
private readonly ILogger m_logger;
|
||||
private ILogger? m_logger;
|
||||
private Variant[]? m_results;
|
||||
private bool[]? m_evaluated;
|
||||
private Variant[]? m_leftValues;
|
||||
@@ -1174,7 +1179,12 @@ namespace Opc.Ua
|
||||
IFilterContext context,
|
||||
IFilterTarget target)
|
||||
{
|
||||
// check if nothing to do.
|
||||
// check if nothing to do: an empty filter passes every target.
|
||||
if (filter.Elements.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var evaluator = new FilterEvaluator(filter, context, target);
|
||||
return evaluator.Result;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ namespace Opc.Ua.Redundancy.Server
|
||||
m_sink = sink;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>The queue changes its events only through the base members.</remarks>
|
||||
internal override bool IndexesRecentEvents => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Enqueue(EventFieldList value)
|
||||
{
|
||||
@@ -167,7 +171,7 @@ namespace Opc.Ua.Redundancy.Server
|
||||
return new EventQueueSnapshot
|
||||
{
|
||||
QueueSize = QueueSize,
|
||||
Events = [.. m_events]
|
||||
Events = CopyQueuedEvents()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1638,20 +1638,19 @@ namespace Opc.Ua.Server
|
||||
/// Fetches the event fields from the event.
|
||||
/// </summary>
|
||||
private EventFieldList GetEventFields(
|
||||
IFilterContext context,
|
||||
FilterContext context,
|
||||
EventFilter filter,
|
||||
IFilterTarget instance)
|
||||
{
|
||||
// fetch the event fields.
|
||||
bool[] rejected = GetRejectedSelectClauses(context, filter);
|
||||
var eventFieldValues = new List<Variant>(rejected.Length);
|
||||
var eventFieldValues = new Variant[rejected.Length];
|
||||
for (int ii = 0; ii < rejected.Length; ii++)
|
||||
{
|
||||
// a select clause rejected in the EventFilterResult returns a null field
|
||||
// (Part 4 §7.22.3).
|
||||
if (rejected[ii])
|
||||
{
|
||||
eventFieldValues.Add(Variant.Null);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1665,22 +1664,16 @@ namespace Opc.Ua.Server
|
||||
clause.AttributeId,
|
||||
clause.ParsedIndexRange);
|
||||
|
||||
// add the value to the list of event fields.
|
||||
// a missing value stays a null field.
|
||||
if (!value.IsNull)
|
||||
{
|
||||
// translate any localized text (scalar or array).
|
||||
if (value.TypeInfo.BuiltInType == BuiltInType.LocalizedText)
|
||||
{
|
||||
value = m_server.ResourceManager.TranslateValue(Session?.PreferredLocales!, value);
|
||||
value = m_server.ResourceManager.TranslateValue(context.PreferredLocales, value);
|
||||
}
|
||||
|
||||
// add value.
|
||||
eventFieldValues.Add(value);
|
||||
}
|
||||
// add a dummy entry for missing values.
|
||||
else
|
||||
{
|
||||
eventFieldValues.Add(Variant.Null);
|
||||
eventFieldValues[ii] = value;
|
||||
}
|
||||
}
|
||||
var result = (EventFieldList)EventFieldListActivator.Instance.CreateInstance();
|
||||
@@ -1690,6 +1683,45 @@ namespace Opc.Ua.Server
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the context to evaluate the event filter with. The context only carries
|
||||
/// the server tables and the preferred locales of the session, so it is created again
|
||||
/// only when the session replaces its locales (or the item moves to another session).
|
||||
/// </summary>
|
||||
private FilterContext GetEventFilterContext()
|
||||
{
|
||||
string[]? preferredLocales = Session?.PreferredLocales;
|
||||
EventFilterContext? cached = m_eventFilterContext;
|
||||
if (cached == null || !ReferenceEquals(cached.PreferredLocales, preferredLocales))
|
||||
{
|
||||
cached = new EventFilterContext(
|
||||
preferredLocales,
|
||||
new FilterContext(
|
||||
m_server.NamespaceUris,
|
||||
m_server.TypeTree,
|
||||
preferredLocales!,
|
||||
m_server.Telemetry));
|
||||
m_eventFilterContext = cached;
|
||||
}
|
||||
return cached.Context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The event filter context and the locales it was created for.
|
||||
/// </summary>
|
||||
private sealed class EventFilterContext
|
||||
{
|
||||
public EventFilterContext(string[]? preferredLocales, FilterContext context)
|
||||
{
|
||||
PreferredLocales = preferredLocales;
|
||||
Context = context;
|
||||
}
|
||||
|
||||
public string[]? PreferredLocales { get; }
|
||||
|
||||
public FilterContext Context { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an event to the queue.
|
||||
/// </summary>
|
||||
@@ -1730,12 +1762,7 @@ namespace Opc.Ua.Server
|
||||
return;
|
||||
}
|
||||
|
||||
// construct the context to use for the event filter.
|
||||
var context = new FilterContext(
|
||||
m_server.NamespaceUris,
|
||||
m_server.TypeTree,
|
||||
Session?.PreferredLocales!,
|
||||
m_server.Telemetry);
|
||||
FilterContext context = GetEventFilterContext();
|
||||
|
||||
// event filter must be specified.
|
||||
if (FilterToUse is not EventFilter filter)
|
||||
@@ -2971,6 +2998,7 @@ namespace Opc.Ua.Server
|
||||
private bool m_resendData;
|
||||
private HashSet<string>? m_filteredRetainConditionIds;
|
||||
private RejectedSelectClauses? m_rejectedSelectClauses;
|
||||
private EventFilterContext? m_eventFilterContext;
|
||||
private bool m_isDetached;
|
||||
private bool m_isDeleted;
|
||||
private bool m_countedAsRecoverable;
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Opc.Ua.Server
|
||||
@@ -40,6 +41,12 @@ namespace Opc.Ua.Server
|
||||
{
|
||||
private const uint kMaxNoOfEntriesCheckedForDuplicateEvents = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// The number of dequeued slots at the front of <see cref="m_events"/> from which the
|
||||
/// list is compacted once they are half of it.
|
||||
/// </summary>
|
||||
private const int kMinDequeuedSlotsToCompact = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty queue.
|
||||
/// </summary>
|
||||
@@ -68,16 +75,32 @@ namespace Opc.Ua.Server
|
||||
public uint QueueSize { get; protected set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int ItemsInQueue => m_events.Count;
|
||||
public int ItemsInQueue => m_events.Count - m_head;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual bool Dequeue(out EventFieldList value)
|
||||
{
|
||||
value = null!;
|
||||
if (m_events.Count != 0)
|
||||
if (m_head < m_events.Count)
|
||||
{
|
||||
value = m_events[0];
|
||||
m_events.RemoveAt(0);
|
||||
// advance the head instead of shifting the list: removing the first entry
|
||||
// moved every queued event for each dequeued one.
|
||||
value = m_events[m_head];
|
||||
if (IndexesRecentEvents && ItemsInQueue <= kMaxNoOfEntriesCheckedForDuplicateEvents)
|
||||
{
|
||||
RemoveRecentHandle(value);
|
||||
}
|
||||
m_events[m_head++] = null!;
|
||||
if (m_head == m_events.Count)
|
||||
{
|
||||
m_events.Clear();
|
||||
m_head = 0;
|
||||
}
|
||||
else if (m_head >= kMinDequeuedSlotsToCompact && m_head * 2 >= m_events.Count)
|
||||
{
|
||||
// moves fewer events than were dequeued since the last compaction.
|
||||
CompactEvents();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -108,28 +131,46 @@ namespace Opc.Ua.Server
|
||||
}
|
||||
|
||||
//Discard oldest
|
||||
if (m_events.Count == QueueSize)
|
||||
if (ItemsInQueue == QueueSize)
|
||||
{
|
||||
Dequeue(out EventFieldList _);
|
||||
}
|
||||
|
||||
m_events.Add(value);
|
||||
|
||||
if (IndexesRecentEvents)
|
||||
{
|
||||
AddRecentHandle(value);
|
||||
if (ItemsInQueue > kMaxNoOfEntriesCheckedForDuplicateEvents)
|
||||
{
|
||||
// the entry that fell out of the newest entries is no longer checked.
|
||||
RemoveRecentHandle(
|
||||
m_events[m_events.Count - 1 - (int)kMaxNoOfEntriesCheckedForDuplicateEvents]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsEventContainedInQueue(IFilterTarget instance)
|
||||
{
|
||||
// a duplicate is the same instance reported again through another notifier
|
||||
// path, so it sits among the most recently queued events: scan from the newest.
|
||||
// path, so it sits among the most recently queued events. The handles of the
|
||||
// newest entries are counted in an index, so the check does not visit every
|
||||
// queued event for each event that is not a duplicate.
|
||||
if (IndexesRecentEvents)
|
||||
{
|
||||
return instance != null && m_recentHandles != null && m_recentHandles.ContainsKey(instance);
|
||||
}
|
||||
|
||||
// a derived queue may change m_events directly, which the index cannot follow.
|
||||
int lowestIndex =
|
||||
m_events.Count > kMaxNoOfEntriesCheckedForDuplicateEvents
|
||||
ItemsInQueue > kMaxNoOfEntriesCheckedForDuplicateEvents
|
||||
? m_events.Count - (int)kMaxNoOfEntriesCheckedForDuplicateEvents
|
||||
: 0;
|
||||
: m_head;
|
||||
|
||||
for (int i = m_events.Count - 1; i >= lowestIndex; i--)
|
||||
{
|
||||
if (m_events[i] is EventFieldList processedEvent &&
|
||||
IsEventRaisedFor(processedEvent, instance))
|
||||
if (IsEventRaisedFor(m_events[i], instance))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -137,6 +178,13 @@ namespace Opc.Ua.Server
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the duplicate check uses the index of the newest queued events. Only
|
||||
/// queues that change <see cref="m_events"/> solely through the members of this class
|
||||
/// may use it; any other derived queue scans the newest entries instead.
|
||||
/// </summary>
|
||||
internal virtual bool IndexesRecentEvents => GetType() == typeof(EventMonitoredItemQueue);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the queued event was raised for <paramref name="instance"/>, for
|
||||
/// the duplicate check of <see cref="IEventMonitoredItemQueue.IsEventContainedInQueue"/>
|
||||
@@ -161,6 +209,7 @@ namespace Opc.Ua.Server
|
||||
public virtual void SetQueueSize(uint queueSize, bool discardOldest)
|
||||
{
|
||||
QueueSize = queueSize;
|
||||
CompactEvents();
|
||||
|
||||
if (m_events.Count > QueueSize)
|
||||
{
|
||||
@@ -173,13 +222,128 @@ namespace Opc.Ua.Server
|
||||
m_events.RemoveRange((int)queueSize, m_events.Count - (int)queueSize);
|
||||
}
|
||||
}
|
||||
|
||||
if (IndexesRecentEvents)
|
||||
{
|
||||
RebuildRecentHandles();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// the contained in the queue
|
||||
/// Copies the queued events, ordered oldest to newest.
|
||||
/// </summary>
|
||||
internal EventFieldList[] CopyQueuedEvents()
|
||||
{
|
||||
var events = new EventFieldList[ItemsInQueue];
|
||||
m_events.CopyTo(m_head, events, 0, events.Length);
|
||||
return events;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the dequeued slots at the front of <see cref="m_events"/>.
|
||||
/// </summary>
|
||||
private void CompactEvents()
|
||||
{
|
||||
if (m_head > 0)
|
||||
{
|
||||
m_events.RemoveRange(0, m_head);
|
||||
m_head = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the handles of the newest queued events again after the queue was truncated,
|
||||
/// and drops the handles of the events that left the queue.
|
||||
/// </summary>
|
||||
private void RebuildRecentHandles()
|
||||
{
|
||||
m_recentHandles?.Clear();
|
||||
int count = m_events.Count;
|
||||
int lowestIndex = Math.Max(m_head, count - (int)kMaxNoOfEntriesCheckedForDuplicateEvents);
|
||||
for (int i = lowestIndex; i < count; i++)
|
||||
{
|
||||
AddRecentHandle(m_events[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the event instance that <paramref name="queuedEvent"/> was raised for.
|
||||
/// </summary>
|
||||
private void AddRecentHandle(EventFieldList queuedEvent)
|
||||
{
|
||||
object? key = GetRaisedFor(queuedEvent);
|
||||
if (key != null)
|
||||
{
|
||||
m_recentHandles ??= new Dictionary<object, int>(ReferenceComparer.Instance);
|
||||
m_recentHandles.TryGetValue(key, out int count);
|
||||
m_recentHandles[key] = count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uncounts the event instance that <paramref name="queuedEvent"/> was raised for.
|
||||
/// </summary>
|
||||
private void RemoveRecentHandle(EventFieldList queuedEvent)
|
||||
{
|
||||
object? key = GetRaisedFor(queuedEvent);
|
||||
if (key != null && m_recentHandles != null && m_recentHandles.TryGetValue(key, out int count))
|
||||
{
|
||||
if (count > 1)
|
||||
{
|
||||
m_recentHandles[key] = count - 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_recentHandles.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The event instance a queued event was raised for, see <see cref="IsEventRaisedFor"/>.
|
||||
/// </summary>
|
||||
private static object? GetRaisedFor(EventFieldList queuedEvent)
|
||||
{
|
||||
object? handle = queuedEvent?.Handle;
|
||||
return handle is FilteredRetainTarget wrapper ? wrapper.Target : handle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares event instances by reference.
|
||||
/// </summary>
|
||||
private sealed class ReferenceComparer : IEqualityComparer<object>
|
||||
{
|
||||
public static readonly ReferenceComparer Instance = new();
|
||||
|
||||
public new bool Equals(object? x, object? y)
|
||||
{
|
||||
return ReferenceEquals(x, y);
|
||||
}
|
||||
|
||||
public int GetHashCode(object obj)
|
||||
{
|
||||
return RuntimeHelpers.GetHashCode(obj);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The events in the queue, oldest first. A dequeued event leaves a null slot at the
|
||||
/// front of the list until the list is compacted, so the queued events are the last
|
||||
/// <see cref="ItemsInQueue"/> entries.
|
||||
/// </summary>
|
||||
protected List<EventFieldList> m_events;
|
||||
|
||||
/// <summary>
|
||||
/// The index of the oldest queued event in <see cref="m_events"/>.
|
||||
/// </summary>
|
||||
private int m_head;
|
||||
|
||||
/// <summary>
|
||||
/// How many times each event instance is the handle of one of the newest
|
||||
/// <see cref="kMaxNoOfEntriesCheckedForDuplicateEvents"/> queued events.
|
||||
/// </summary>
|
||||
private Dictionary<object, int>? m_recentHandles;
|
||||
|
||||
/// <summary>
|
||||
/// The logger to use here and in derived classes
|
||||
/// </summary>
|
||||
|
||||
@@ -395,6 +395,41 @@ namespace Opc.Ua.Core.Tests.Stack.State
|
||||
Assert.That(clone.Parent, Is.SameAs(parent));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that the fields of an initialized event carry the event time as their
|
||||
/// source timestamp, so reading them does not stamp them with the current time.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void InitializedEventFieldsCarryTheEventTime()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
var context = new SystemContext(telemetry) { NamespaceUris = new NamespaceTable() };
|
||||
var source = new BaseObjectState(null)
|
||||
{
|
||||
NodeId = new NodeId(1000),
|
||||
BrowseName = QualifiedName.From("Source")
|
||||
};
|
||||
var eventState = new BaseEventState(null);
|
||||
eventState.Initialize(context, source, EventSeverity.Medium, new LocalizedText("message"));
|
||||
|
||||
DateTimeUtc time = eventState.Time!.Value;
|
||||
Assert.That(time, Is.Not.EqualTo(DateTimeUtc.MinValue));
|
||||
Assert.That(eventState.EventId!.Timestamp, Is.EqualTo(time));
|
||||
Assert.That(eventState.EventType!.Timestamp, Is.EqualTo(time));
|
||||
Assert.That(eventState.SourceNode!.Timestamp, Is.EqualTo(time));
|
||||
Assert.That(eventState.SourceName!.Timestamp, Is.EqualTo(time));
|
||||
Assert.That(eventState.Time.Timestamp, Is.EqualTo(time));
|
||||
Assert.That(eventState.Severity!.Timestamp, Is.EqualTo(time));
|
||||
Assert.That(eventState.Message!.Timestamp, Is.EqualTo(time));
|
||||
Assert.That(eventState.ReceiveTime!.Timestamp, Is.EqualTo(eventState.ReceiveTime.Value));
|
||||
|
||||
var dataValue = new DataValue();
|
||||
ServiceResult result = eventState.Message.ReadAttribute(
|
||||
context, Attributes.Value, default, default, ref dataValue);
|
||||
Assert.That(ServiceResult.IsGood(result), Is.True);
|
||||
Assert.That(dataValue.SourceTimestamp, Is.EqualTo(time));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that Clone works correctly for BaseEventState with null parent.
|
||||
/// </summary>
|
||||
|
||||
@@ -27,8 +27,10 @@
|
||||
* http://opcfoundation.org/License/MIT/1.00/
|
||||
* ======================================================================*/
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Opc.Ua.Tests;
|
||||
@@ -72,6 +74,205 @@ namespace Opc.Ua.Server.Tests
|
||||
Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EventQueueDuplicateCheckFollowsTheNewestEntries()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
using var queue = new EventMonitoredItemQueue(false, 1, telemetry);
|
||||
queue.SetQueueSize(2000, true);
|
||||
var handles = new List<AuditSessionEventState>();
|
||||
for (int i = 0; i < 1200; i++)
|
||||
{
|
||||
var handle = new AuditSessionEventState(null);
|
||||
handles.Add(handle);
|
||||
// a trailing filtered retain event is queued with a wrapper of its instance.
|
||||
object queuedHandle = i % 3 == 0 ? new FilteredRetainTarget(handle) : handle;
|
||||
queue.Enqueue(new EventFieldList { EventFields = [new Variant(i)], Handle = queuedHandle });
|
||||
}
|
||||
|
||||
// only the newest 1000 entries are checked.
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[199]), Is.False);
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[200]), Is.True);
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[201]), Is.True);
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[1199]), Is.True);
|
||||
|
||||
// an instance queued twice stays a duplicate until both entries are gone.
|
||||
queue.Enqueue(new EventFieldList { EventFields = [new Variant(-1)], Handle = handles[1199] });
|
||||
for (int i = 0; i < 1000; i++)
|
||||
{
|
||||
Assert.That(queue.Dequeue(out _), Is.True);
|
||||
}
|
||||
Assert.That(queue.ItemsInQueue, Is.EqualTo(201));
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[999]), Is.False);
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[1000]), Is.True);
|
||||
Assert.That(queue.Dequeue(out EventFieldList last), Is.True);
|
||||
Assert.That(last.EventFields[0].GetInt32(), Is.EqualTo(1000));
|
||||
for (int i = 0; i < 199; i++)
|
||||
{
|
||||
Assert.That(queue.Dequeue(out _), Is.True);
|
||||
}
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[1199]), Is.True);
|
||||
Assert.That(queue.Dequeue(out _), Is.True);
|
||||
Assert.That(queue.ItemsInQueue, Is.Zero);
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[1199]), Is.False);
|
||||
|
||||
// a shrink counts the remaining entries again.
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
queue.Enqueue(new EventFieldList { EventFields = [new Variant(i)], Handle = handles[i] });
|
||||
}
|
||||
queue.SetQueueSize(4, false);
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[3]), Is.True);
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[4]), Is.False);
|
||||
queue.Enqueue(new EventFieldList { EventFields = [new Variant(20)], Handle = handles[20] });
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[0]), Is.False);
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[20]), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EventQueueDuplicateCheckSeesEntriesAddedByDerivedQueue()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
using var queue = new DirectlyFilledEventQueue(telemetry);
|
||||
queue.SetQueueSize(10, true);
|
||||
var queued = new AuditSessionEventState(null);
|
||||
queue.Enqueue(new EventFieldList { EventFields = [new Variant(1)], Handle = queued });
|
||||
|
||||
var added = new AuditSessionEventState(null);
|
||||
queue.AddDirectly(new EventFieldList { EventFields = [new Variant(2)], Handle = added });
|
||||
Assert.That(queue.IsEventContainedInQueue(added), Is.True);
|
||||
Assert.That(queue.IsEventContainedInQueue(queued), Is.True);
|
||||
|
||||
// a queue changed directly stays consistent through further queue operations.
|
||||
queue.AddDirectly(new EventFieldList { EventFields = [new Variant(3)], Handle = added });
|
||||
queue.Enqueue(new EventFieldList { EventFields = [new Variant(4)], Handle = queued });
|
||||
Assert.That(queue.Dequeue(out _), Is.True);
|
||||
Assert.That(queue.Dequeue(out _), Is.True);
|
||||
Assert.That(queue.IsEventContainedInQueue(added), Is.True);
|
||||
Assert.That(queue.IsEventContainedInQueue(queued), Is.True);
|
||||
Assert.That(queue.Dequeue(out _), Is.True);
|
||||
Assert.That(queue.IsEventContainedInQueue(added), Is.False);
|
||||
|
||||
// an entry replaced in place leaves the count unchanged.
|
||||
var replacement = new AuditSessionEventState(null);
|
||||
queue.ReplaceNewest(new EventFieldList { EventFields = [new Variant(5)], Handle = replacement });
|
||||
Assert.That(queue.IsEventContainedInQueue(replacement), Is.True);
|
||||
Assert.That(queue.IsEventContainedInQueue(queued), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EventQueueReleasesEventsAfterResizeAndDrain()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
using var queue = new EventMonitoredItemQueue(false, 1, telemetry);
|
||||
queue.SetQueueSize(10, true);
|
||||
WeakReference released = EnqueueUnreferencedEvent(queue);
|
||||
|
||||
queue.SetQueueSize(10, true);
|
||||
DequeueAndDrop(queue);
|
||||
Assert.That(queue.ItemsInQueue, Is.Zero);
|
||||
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
Assert.That(released.IsAlive, Is.False);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static WeakReference EnqueueUnreferencedEvent(EventMonitoredItemQueue queue)
|
||||
{
|
||||
var handle = new AuditSessionEventState(null);
|
||||
queue.Enqueue(new EventFieldList { EventFields = [new Variant(1)], Handle = handle });
|
||||
Assert.That(queue.IsEventContainedInQueue(handle), Is.True);
|
||||
return new WeakReference(handle);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static void DequeueAndDrop(EventMonitoredItemQueue queue)
|
||||
{
|
||||
Assert.That(queue.Dequeue(out _), Is.True);
|
||||
}
|
||||
|
||||
private sealed class DirectlyFilledEventQueue : EventMonitoredItemQueue
|
||||
{
|
||||
public DirectlyFilledEventQueue(ITelemetryContext telemetry)
|
||||
: base(false, 1, telemetry)
|
||||
{
|
||||
}
|
||||
|
||||
public void AddDirectly(EventFieldList value)
|
||||
{
|
||||
m_events.Add(value);
|
||||
}
|
||||
|
||||
public void ReplaceNewest(EventFieldList value)
|
||||
{
|
||||
m_events[m_events.Count - 1] = value;
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase(true)]
|
||||
[TestCase(false)]
|
||||
public void EventQueueKeepsOrderAcrossDequeueCompactionAndShrink(bool discardOldest)
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
using var queue = new EventMonitoredItemQueue(false, 1, telemetry);
|
||||
queue.SetQueueSize(100, true);
|
||||
var handles = new List<AuditSessionEventState>();
|
||||
|
||||
void Enqueue(int value)
|
||||
{
|
||||
var handle = new AuditSessionEventState(null);
|
||||
handles.Add(handle);
|
||||
queue.Enqueue(new EventFieldList { EventFields = [new Variant(value)], Handle = handle });
|
||||
}
|
||||
|
||||
int Dequeue()
|
||||
{
|
||||
Assert.That(queue.Dequeue(out EventFieldList fields), Is.True);
|
||||
return fields.EventFields[0].GetInt32();
|
||||
}
|
||||
|
||||
// 150 events into a queue of 100 discard the oldest 50.
|
||||
for (int i = 0; i < 150; i++)
|
||||
{
|
||||
Enqueue(i);
|
||||
}
|
||||
Assert.That(queue.ItemsInQueue, Is.EqualTo(100));
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[49]), Is.False);
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[50]), Is.True);
|
||||
|
||||
// dequeue across the compaction threshold, interleaved with new events.
|
||||
int expected = 50;
|
||||
for (int i = 0; i < 70; i++)
|
||||
{
|
||||
Assert.That(Dequeue(), Is.EqualTo(expected++));
|
||||
if (i % 2 == 0)
|
||||
{
|
||||
Enqueue(150 + (i / 2));
|
||||
}
|
||||
}
|
||||
Assert.That(queue.ItemsInQueue, Is.EqualTo(65));
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[119]), Is.False);
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[120]), Is.True);
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[handles.Count - 1]), Is.True);
|
||||
|
||||
// shrinking keeps the oldest or newest of the queued events.
|
||||
queue.SetQueueSize(10, discardOldest);
|
||||
Assert.That(queue.ItemsInQueue, Is.EqualTo(10));
|
||||
int first = discardOldest ? 175 : 120;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
Assert.That(Dequeue(), Is.EqualTo(first + i));
|
||||
}
|
||||
Assert.That(queue.Dequeue(out _), Is.False);
|
||||
Assert.That(queue.ItemsInQueue, Is.Zero);
|
||||
Assert.That(queue.IsEventContainedInQueue(handles[handles.Count - 1]), Is.False);
|
||||
|
||||
Enqueue(1000);
|
||||
Assert.That(Dequeue(), Is.EqualTo(1000));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EventOverflowKeepsPositionWhenNotDiscardingOldest()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user