Files
Copilot b3518b53df [Server]: Queue Events from the Server Node using multiple channel consumers (#3823)
# Description

This pull request introduces a scalable consumer task model for the
`MonitoredNode2` class, optimizing the handling of event notifications
for notifier nodes. Any notifier node can opt into (or out of) dynamic
scaling of consumer tasks based on the number of event monitored items,
improving throughput and responsiveness under high event loads. The
Server node (`ObjectIds.Server`) is always auto-opted-in. The
implementation includes safe scaling up and down of consumers, robust
disposal logic, Fluent API integration, and tests to verify correct
delivery of events.

**Scalable consumer task management:**

* Added an `enableMultipleEventConsumers` constructor parameter to
`MonitoredNode2` that enables dynamic scaling of consumer tasks to match
the number of event monitored items. The Server node
(`ObjectIds.Server`) always auto-opts-in regardless of this parameter.
* Added `AllowMultipleEventConsumers(NodeId, bool enable = true)`
protected method on `AsyncCustomNodeManager` with an internal
`MultiConsumerNodeIds` registry, so any node manager can
programmatically opt in or out specific nodes.
* Added `IsMultipleEventConsumerNode(NodeId)` to the `IAsyncNodeManager`
interface, allowing consumers to query multi-consumer opt-in status
through the interface without casting to `AsyncCustomNodeManager`.
* Both `MonitoredNodeMonitoredItemManager` and
`SamplingGroupMonitoredItemManager` use the `IAsyncNodeManager`
interface method when creating `MonitoredNode2` instances.

**Fluent API integration:**

* Added `AllowMultipleEventConsumers(bool enable = true)` method to
`INodeBuilder` and its `NodeBuilder` implementation, allowing any
notifier node to be opted in or out via the fluent builder pipeline
(e.g. `builder.Node("myNotifier").AllowMultipleEventConsumers()`).
* The `NodeManagerBuilder` routes the registration to the owning
`AsyncCustomNodeManager`'s multi-consumer registry, supporting both
opt-in and opt-out.

**Testing and verification:**

* Added unit tests to verify multi-consumer behavior for both Server
node (auto-opt-in) and non-Server nodes (explicit opt-in), as well as
default single-consumer behavior for nodes without opt-in.
* Fixed race condition in permission cache tests
(`OnMonitoredNodeChanged_DefaultPermissionsChanged_CacheInvalidated` and
`InvalidatePermissionCacheForSession` tests) that caused flaky CI
failures. The tests now signal on `QueueValue` (which fires after the
cache is populated) instead of `ValidateRolePermissionsAsync` (which
fires before), ensuring cache invalidation operates on a populated
cache.

## Related Issues

## 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.
- [ ] 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.
- [x] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-02 07:14:36 +02:00

353 lines
13 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
namespace Opc.Ua.Server
{
/// <summary>
/// Manages the montioredItems for a NodeManager
/// </summary>
public class SamplingGroupMonitoredItemManager : IMonitoredItemManager
{
/// <inheritdoc/>
public SamplingGroupMonitoredItemManager(
IAsyncNodeManager nodeManager,
IServerInternal server,
ApplicationConfiguration configuration)
{
m_samplingGroupManager = new SamplingGroupManager(
server,
nodeManager,
(uint)configuration.ServerConfiguration!.MaxNotificationQueueSize,
(uint)configuration.ServerConfiguration.MaxDurableNotificationQueueSize,
configuration.ServerConfiguration.AvailableSamplingRates.ToArray()!);
m_nodeManager = nodeManager;
m_server = server;
MonitoredNodes = [];
MonitoredItems = new ConcurrentDictionary<uint, IMonitoredItem>();
}
/// <inheritdoc/>
public NodeIdDictionary<MonitoredNode2> MonitoredNodes { get; }
/// <inheritdoc/>
public ConcurrentDictionary<uint, IMonitoredItem> MonitoredItems { get; }
/// <inheritdoc/>
public ISampledDataChangeMonitoredItem CreateMonitoredItem(
IServerInternal server,
IAsyncNodeManager nodeManager,
ServerSystemContext context,
NodeHandle handle,
uint subscriptionId,
double publishingInterval,
DiagnosticsMasks diagnosticsMasks,
TimestampsToReturn timestampsToReturn,
MonitoredItemCreateRequest itemToCreate,
Range euRange,
MonitoringFilter filterToUse,
double samplingInterval,
uint revisedQueueSize,
bool createDurable,
MonitoredItemIdFactory monitoredItemIdFactory,
Func<ISystemContext, NodeHandle, NodeState, NodeState> addNodeToComponentCache)
{
// set min sampling interval if 0
if (samplingInterval.CompareTo(0.0) == 0)
{
samplingInterval = 1;
}
uint monitoredItemId = monitoredItemIdFactory.GetNextId();
// create monitored item.
ISampledDataChangeMonitoredItem monitoredItem =
m_samplingGroupManager.CreateMonitoredItem(
context.OperationContext!,
subscriptionId,
publishingInterval,
timestampsToReturn,
monitoredItemId,
handle,
itemToCreate,
euRange,
samplingInterval,
createDurable);
// save the monitored item.
MonitoredItems.AddOrUpdate(
monitoredItemId,
monitoredItem,
(key, oldValue) => monitoredItem);
return monitoredItem;
}
/// <inheritdoc/>
public void ApplyChanges()
{
// update all groups with any new items.
m_samplingGroupManager.ApplyChanges();
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// An overrideable version of the Dispose.
/// </summary>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
m_samplingGroupManager?.Dispose();
}
}
/// <inheritdoc/>
public StatusCode DeleteMonitoredItem(
ServerSystemContext context,
ISampledDataChangeMonitoredItem monitoredItem,
NodeHandle handle)
{
// validate monitored item.
if (!MonitoredItems.TryGetValue(
monitoredItem.Id,
out IMonitoredItem? existingMonitoredItem))
{
return StatusCodes.BadMonitoredItemIdInvalid;
}
if (!ReferenceEquals(monitoredItem, existingMonitoredItem))
{
return StatusCodes.BadMonitoredItemIdInvalid;
}
// remove item.
m_samplingGroupManager.StopMonitoring(monitoredItem);
// remove association with the group.
MonitoredItems.TryRemove(monitoredItem.Id, out _);
// delete successful.
return StatusCodes.Good;
}
/// <inheritdoc/>
public ServiceResult? ModifyMonitoredItem(
ServerSystemContext context,
DiagnosticsMasks diagnosticsMasks,
TimestampsToReturn timestampsToReturn,
MonitoringFilter filterToUse,
Range euRange,
double samplingInterval,
uint revisedQueueSize,
ISampledDataChangeMonitoredItem monitoredItem,
MonitoredItemModifyRequest itemToModify)
{
// validate monitored item.
if (!MonitoredItems.TryGetValue(
monitoredItem.Id,
out IMonitoredItem? existingMonitoredItem))
{
return StatusCodes.BadMonitoredItemIdInvalid;
}
if (!ReferenceEquals(monitoredItem, existingMonitoredItem))
{
return StatusCodes.BadMonitoredItemIdInvalid;
}
return m_samplingGroupManager.ModifyMonitoredItem(
context.OperationContext!,
timestampsToReturn,
monitoredItem,
itemToModify,
euRange);
}
/// <inheritdoc/>
public ValueTask<(ServiceResult, MonitoringMode?)> SetMonitoringModeAsync(
ServerSystemContext context,
ISampledDataChangeMonitoredItem monitoredItem,
MonitoringMode monitoringMode,
NodeHandle handle,
CancellationToken cancellationToken = default)
{
if (!MonitoredItems.TryGetValue(
monitoredItem.Id,
out IMonitoredItem? existingMonitoredItem))
{
return new ValueTask<(ServiceResult, MonitoringMode?)>((StatusCodes.BadMonitoredItemIdInvalid, null));
}
if (!ReferenceEquals(monitoredItem, existingMonitoredItem))
{
return new ValueTask<(ServiceResult, MonitoringMode?)>((StatusCodes.BadMonitoredItemIdInvalid, null));
}
// update monitoring mode.
MonitoringMode previousMode = monitoredItem.SetMonitoringMode(monitoringMode);
// need to provide an immediate update after enabling.
if (previousMode == MonitoringMode.Disabled &&
monitoringMode != MonitoringMode.Disabled)
{
var initialValue = new DataValue(
Variant.Null,
StatusCodes.BadWaitingForInitialData,
DateTimeUtc.MinValue,
DateTime.UtcNow);
// read the initial value.
if (monitoredItem.ManagerHandle is Node node)
{
ServiceResult error = node.Read(
context,
monitoredItem.AttributeId,
ref initialValue);
if (ServiceResult.IsBad(error))
{
initialValue = initialValue
.WithWrappedValue(default)
.WithStatus(error.StatusCode);
}
}
monitoredItem.QueueValue(initialValue, null);
}
return new ValueTask<(ServiceResult, MonitoringMode?)>((StatusCodes.Good, previousMode));
}
/// <inheritdoc/>
public bool RestoreMonitoredItem(
IServerInternal server,
IAsyncNodeManager nodeManager,
ServerSystemContext context,
NodeHandle handle,
IStoredMonitoredItem storedMonitoredItem,
IUserIdentity savedOwnerIdentity,
Func<ISystemContext, NodeHandle, NodeState, NodeState> addNodeToComponentCache,
out ISampledDataChangeMonitoredItem monitoredItem)
{
// create monitored item.
monitoredItem = m_samplingGroupManager.RestoreMonitoredItem(
handle,
storedMonitoredItem,
savedOwnerIdentity);
// save monitored item.
MonitoredItems.TryAdd(monitoredItem.Id, monitoredItem);
return true;
}
/// <inheritdoc/>
public (MonitoredNode2?, ServiceResult) SubscribeToEvents(
ServerSystemContext context,
NodeState source,
IEventMonitoredItem monitoredItem,
bool unsubscribe)
{
MonitoredNode2? monitoredNode;
// handle unsubscribe.
if (unsubscribe)
{
// check for existing monitored node.
if (!MonitoredNodes.TryGetValue(source.NodeId, out monitoredNode))
{
return (null, StatusCodes.BadNodeIdUnknown);
}
monitoredNode.Remove(monitoredItem);
MonitoredItems.TryRemove(monitoredItem.Id, out _);
// check if node is no longer being monitored.
if (!monitoredNode.HasMonitoredItems)
{
MonitoredNodes.Remove(source.NodeId);
}
return (monitoredNode, ServiceResult.Good);
}
// only objects or views can be subscribed to.
if (source is not BaseObjectState instance ||
(instance.EventNotifier & EventNotifiers.SubscribeToEvents) == 0)
{
if (source is not ViewState view ||
(view.EventNotifier & EventNotifiers.SubscribeToEvents) == 0)
{
return (null, StatusCodes.BadNotSupported);
}
}
// check for existing monitored node.
if (!MonitoredNodes.TryGetValue(source.NodeId, out monitoredNode))
{
MonitoredNodes[source.NodeId]
= monitoredNode = new MonitoredNode2(m_nodeManager, m_server, source,
IsMultiConsumerNode(source.NodeId));
}
// remove existing monitored items with the same Id prior to insertion in order to avoid duplicates
// this is necessary since the SubscribeToEvents method is called also from ModifyMonitoredItemsForEvents
monitoredNode.EventMonitoredItems.TryRemove(monitoredItem.Id, out _);
// this links the node to specified monitored item and ensures all events
// reported by the node are added to the monitored item's queue.
monitoredNode.Add(monitoredItem);
MonitoredItems.TryAdd(monitoredItem.Id, monitoredItem);
return (monitoredNode, ServiceResult.Good);
}
private bool IsMultiConsumerNode(NodeId nodeId)
{
return m_nodeManager.IsMultipleEventConsumerNode(nodeId);
}
private readonly IAsyncNodeManager m_nodeManager;
private readonly IServerInternal m_server;
private readonly SamplingGroupManager m_samplingGroupManager;
}
}