mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 07:07:24 +08:00
# 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>
370 lines
13 KiB
C#
370 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;
|
|
|
|
namespace Opc.Ua.Server.Fluent
|
|
{
|
|
/// <summary>
|
|
/// Default <see cref="INodeBuilder"/> implementation. Wires variable,
|
|
/// method, event, and condition-refresh callbacks directly onto the
|
|
/// resolved <see cref="NodeState"/>; routes manager-level callbacks
|
|
/// (history, monitored-item, lifecycle) through the parent
|
|
/// <see cref="NodeManagerBuilder"/>'s dispatcher.
|
|
/// </summary>
|
|
internal class NodeBuilder : INodeBuilder
|
|
{
|
|
public NodeBuilder(NodeManagerBuilder parent, NodeState node)
|
|
{
|
|
m_parent = parent ?? throw new ArgumentNullException(nameof(parent));
|
|
Node = node ?? throw new ArgumentNullException(nameof(node));
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public NodeState Node { get; }
|
|
|
|
/// <inheritdoc/>
|
|
public INodeManagerBuilder Builder => m_parent;
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder<TState> As<TState>() where TState : NodeState
|
|
{
|
|
if (Node is not TState typed)
|
|
{
|
|
throw ServiceResultException.Create(
|
|
StatusCodes.BadTypeMismatch,
|
|
"Node '{0}' is of type {1}, which is not assignable to {2}.",
|
|
Node.BrowseName,
|
|
Node.GetType().Name,
|
|
typeof(TState).Name);
|
|
}
|
|
|
|
return new NodeBuilder<TState>(m_parent, typed);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnRead(NodeValueEventHandler handler)
|
|
{
|
|
BaseVariableState v = RequireVariable("OnRead");
|
|
ThrowIfSlotOccupied(v.OnReadValue, "OnRead");
|
|
v.OnReadValue = handler ?? throw new ArgumentNullException(nameof(handler));
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnRead(NodeValueSimpleEventHandler handler)
|
|
{
|
|
BaseVariableState v = RequireVariable("OnSimpleRead");
|
|
ThrowIfSlotOccupied(v.OnSimpleReadValue, "OnSimpleRead");
|
|
v.OnSimpleReadValue = handler ?? throw new ArgumentNullException(nameof(handler));
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnWrite(NodeValueEventHandler handler)
|
|
{
|
|
BaseVariableState v = RequireVariable("OnWrite");
|
|
ThrowIfSlotOccupied(v.OnWriteValue, "OnWrite");
|
|
v.OnWriteValue = handler ?? throw new ArgumentNullException(nameof(handler));
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnWrite(NodeValueSimpleEventHandler handler)
|
|
{
|
|
BaseVariableState v = RequireVariable("OnSimpleWrite");
|
|
ThrowIfSlotOccupied(v.OnSimpleWriteValue, "OnSimpleWrite");
|
|
v.OnSimpleWriteValue = handler ?? throw new ArgumentNullException(nameof(handler));
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnRead(NodeValueEventHandlerAsync handler)
|
|
{
|
|
BaseVariableState v = RequireVariable("OnReadAsync");
|
|
ThrowIfSlotOccupied(v.OnReadValueAsync, "OnReadAsync");
|
|
v.OnReadValueAsync = handler ?? throw new ArgumentNullException(nameof(handler));
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnRead(NodeValueSimpleEventHandlerAsync handler)
|
|
{
|
|
BaseVariableState v = RequireVariable("OnSimpleReadAsync");
|
|
ThrowIfSlotOccupied(v.OnSimpleReadValueAsync, "OnSimpleReadAsync");
|
|
v.OnSimpleReadValueAsync = handler ?? throw new ArgumentNullException(nameof(handler));
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnWrite(NodeValueWriteEventHandlerAsync handler)
|
|
{
|
|
BaseVariableState v = RequireVariable("OnWriteAsync");
|
|
ThrowIfSlotOccupied(v.OnWriteValueAsync, "OnWriteAsync");
|
|
v.OnWriteValueAsync = handler ?? throw new ArgumentNullException(nameof(handler));
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnWrite(NodeValueSimpleWriteEventHandlerAsync handler)
|
|
{
|
|
BaseVariableState v = RequireVariable("OnSimpleWriteAsync");
|
|
ThrowIfSlotOccupied(v.OnSimpleWriteValueAsync, "OnSimpleWriteAsync");
|
|
v.OnSimpleWriteValueAsync = handler ?? throw new ArgumentNullException(nameof(handler));
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnCall(GenericMethodCalledEventHandler2 handler)
|
|
{
|
|
MethodState m = RequireMethod("OnCall");
|
|
ThrowIfSlotOccupied(m.OnCallMethod2, "OnCall");
|
|
m.OnCallMethod2 = handler ?? throw new ArgumentNullException(nameof(handler));
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnCall(GenericMethodCalledEventHandler2Async handler)
|
|
{
|
|
MethodState m = RequireMethod("OnCallAsync");
|
|
ThrowIfSlotOccupied(m.OnCallMethod2Async, "OnCallAsync");
|
|
m.OnCallMethod2Async = handler ?? throw new ArgumentNullException(nameof(handler));
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnNodeAdded(NodeLifecycleHandler handler)
|
|
{
|
|
if (handler == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(handler));
|
|
}
|
|
m_parent.RegisterNodeAdded(Node, handler);
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnNodeRemoved(NodeLifecycleHandler handler)
|
|
{
|
|
if (handler == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(handler));
|
|
}
|
|
m_parent.RegisterNodeRemoved(Node, handler);
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnHistoryRead(HistoryReadHandler handler)
|
|
{
|
|
if (handler == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(handler));
|
|
}
|
|
m_parent.RegisterHistoryRead(Node, handler);
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnHistoryUpdate(HistoryUpdateHandler handler)
|
|
{
|
|
if (handler == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(handler));
|
|
}
|
|
m_parent.RegisterHistoryUpdate(Node, handler);
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnConditionRefresh(ConditionRefreshHandler handler)
|
|
{
|
|
if (handler == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(handler));
|
|
}
|
|
ThrowIfSlotOccupied(Node.OnConditionRefresh, "OnConditionRefresh");
|
|
// Direct wire — signature matches NodeStateConditionRefreshEventHandler.
|
|
Node.OnConditionRefresh = (ctx, n, evts) => handler(ctx, n, evts);
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnMonitoredItemCreated(MonitoredItemCreatedHandler handler)
|
|
{
|
|
if (handler == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(handler));
|
|
}
|
|
m_parent.RegisterMonitoredItemCreated(Node, handler);
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder OnEvent(EventNotificationHandler handler)
|
|
{
|
|
if (handler == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(handler));
|
|
}
|
|
// OnReportEvent is also used by CustomNodeManager2 for root notifier
|
|
// wiring — replacing it would silently break server event propagation.
|
|
ThrowIfSlotOccupied(Node.OnReportEvent, "OnEvent (NodeState.OnReportEvent)");
|
|
Node.OnReportEvent = (ctx, n, e) => handler(ctx, n, e);
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder AllowMultipleEventConsumers(bool enable = true)
|
|
{
|
|
m_parent.RegisterMultiConsumerNode(Node, enable);
|
|
return this;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder Child(QualifiedName browseName)
|
|
{
|
|
NodeState child = ResolveChild(browseName);
|
|
return new NodeBuilder(m_parent, child);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public INodeBuilder<TState> Child<TState>(QualifiedName browseName)
|
|
where TState : NodeState
|
|
{
|
|
NodeState child = ResolveChild(browseName);
|
|
if (child is not TState typed)
|
|
{
|
|
throw ServiceResultException.Create(
|
|
StatusCodes.BadTypeMismatch,
|
|
"Child '{0}' under '{1}' is of type {2}, which is not assignable to {3}.",
|
|
browseName,
|
|
Node.BrowseName,
|
|
child.GetType().Name,
|
|
typeof(TState).Name);
|
|
}
|
|
return new NodeBuilder<TState>(m_parent, typed);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public IVariableBuilder<TValue> Variable<TValue>(QualifiedName browseName)
|
|
{
|
|
NodeState child = ResolveChild(browseName);
|
|
return m_parent.ToVariableBuilder<TValue>(
|
|
child,
|
|
CoreUtils.Format("{0}/{1}", Node.BrowseName, browseName));
|
|
}
|
|
|
|
private BaseInstanceState ResolveChild(QualifiedName browseName)
|
|
{
|
|
if (browseName.IsNull)
|
|
{
|
|
throw ServiceResultException.Create(
|
|
StatusCodes.BadBrowseNameInvalid,
|
|
"Browse name is null or empty.");
|
|
}
|
|
return Node.FindChild(m_parent.Context, browseName) ??
|
|
throw ServiceResultException.Create(
|
|
StatusCodes.BadNodeIdUnknown,
|
|
"Child '{0}' not found under '{1}' (id '{2}').",
|
|
browseName,
|
|
Node.BrowseName,
|
|
Node.NodeId);
|
|
}
|
|
|
|
private BaseVariableState RequireVariable(string what)
|
|
{
|
|
if (Node is not BaseVariableState v)
|
|
{
|
|
throw ServiceResultException.Create(
|
|
StatusCodes.BadInvalidArgument,
|
|
"Cannot wire {0} on node '{1}': not a BaseVariableState (actual: {2}).",
|
|
what,
|
|
Node.BrowseName,
|
|
Node.GetType().Name);
|
|
}
|
|
|
|
return v;
|
|
}
|
|
|
|
private MethodState RequireMethod(string what)
|
|
{
|
|
if (Node is not MethodState m)
|
|
{
|
|
throw ServiceResultException.Create(
|
|
StatusCodes.BadInvalidArgument,
|
|
"Cannot wire {0} on node '{1}': not a MethodState (actual: {2}).",
|
|
what,
|
|
Node.BrowseName,
|
|
Node.GetType().Name);
|
|
}
|
|
|
|
return m;
|
|
}
|
|
|
|
private void ThrowIfSlotOccupied(Delegate? existing, string what)
|
|
{
|
|
if (existing != null)
|
|
{
|
|
throw ServiceResultException.Create(
|
|
StatusCodes.BadConfigurationError,
|
|
"Node '{0}' (id '{1}') already has a {2} handler assigned.",
|
|
Node.BrowseName,
|
|
Node.NodeId,
|
|
what);
|
|
}
|
|
}
|
|
|
|
private readonly NodeManagerBuilder m_parent;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Strongly-typed <see cref="INodeBuilder{TState}"/> wrapper. Inherits
|
|
/// all <c>On*</c> behavior from the non-generic
|
|
/// <see cref="NodeBuilder"/>; the only added value is a typed
|
|
/// <see cref="Node"/> property.
|
|
/// </summary>
|
|
/// <typeparam name="TState">
|
|
/// The concrete <see cref="NodeState"/> derivative the resolved node
|
|
/// is narrowed to.
|
|
/// </typeparam>
|
|
internal class NodeBuilder<TState> : NodeBuilder, INodeBuilder<TState>
|
|
where TState : NodeState
|
|
{
|
|
public NodeBuilder(NodeManagerBuilder parent, TState node)
|
|
: base(parent, node)
|
|
{
|
|
Node = node;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public new TState Node { get; }
|
|
}
|
|
}
|