Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/NodeManager/MonitoredItemManagerLifecycleTests.cs
romanettandClaude Opus 5.5 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# Description

Nullable reference types are now enabled once, in `common.props`, and
every project in the repository builds warning-clean with them.

**Configuration**
- `<Nullable>enable</Nullable>` lives in `common.props`. All per-project
`<Nullable>` settings are removed: 178 project files (enable,
annotations, and the `disable` in the three Aot test projects).
- 419 per-file `#nullable enable`/`restore` directives and the 4
`#nullable disable` directives are removed.
- Kept on purpose:
- The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type
Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`,
`PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the
project setting in generated code, so the directive is required there.
- `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against
the 1.5.378 packages and does not import the repository props.
- `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now
describe the central setting.

**Migration** (the 43 unset and 13 annotations-only projects, roughly
10k diagnostics)
- **Production code: annotated at the declarations.** Covers
`tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the
LDS sample.
- Members, parameters and returns are nullable where null actually
flows.
- Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`,
`IsMethodTypeDesign`).
- `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take
`params object?[]`. `Template.AddReplacement` takes `string?` (rendering
already skips null replacements).
- `IRegisteredServerStore.RegisterAsync` returns
`Task<RegistrationEntry?>`: it returns null when a registration is
removed.
- Dereferences backed by an invariant (for example, the validated model)
use `!`, so runtime behaviour is unchanged.
- **Behavioural changes in production code** (small, deliberate):
- `NodeSetToModelDesign` throws `InvalidOperationException` when the
file is not a NodeSet. Before, it threw a NullReferenceException on the
next line.
- `ModelDesignValidator` starts its lookup tables empty instead of null.
A redundant `if (m_nodes == null) return;` guard is gone.
- **Test code: minimal annotations.**
  - `?` on values that can be null, `!` where the test asserts non-null.
  - `= null!` for fixture members assigned in SetUp.
- `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the
`IEncodeable` constraint.
- A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`)
folded into the adjacent `IsNull`/`Count` checks.

**Notes for reviewers**
- The diff is large (~1,500 files) but almost entirely mechanical: `?`,
`!` and directive removal. The non-annotation changes are listed above.
- Known follow-up, not done here:
`IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay
non-nullable even though the validator returns null before it loads a
dictionary. Making them nullable would ripple through every generator
call site.

## Related Issues

- Fixes #4439

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

The "no new warnings" box covers net10.0 and net48: full non-incremental
builds of `UA.slnx`, `tools/SourceGeneration.slnx`,
`tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0
warnings and 0 errors under the repository's warnings-as-errors
settings. net9.0 and net8.0 also had no nullable diagnostics, but they
were not rebuilt after the final fixes. Tests have not been run locally
yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:48:13 +02:00

2202 lines
101 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 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.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
using Moq;
using NUnit.Framework;
using Opc.Ua.Server.Tests.NodeManager;
namespace Opc.Ua.Server.Tests
{
[TestFixture]
[Category("NodeManager")]
[Category("MonitoredItemLifecycle")]
[Parallelizable(ParallelScope.All)]
public sealed class MonitoredItemManagerLifecycleTests
{
public enum ManagerPath
{
AsyncMonitoredNode,
AsyncSamplingGroup,
SyncAdapterMonitoredNode,
SyncAdapterSamplingGroup
}
[TestCase(ManagerPath.AsyncMonitoredNode)]
[TestCase(ManagerPath.AsyncSamplingGroup)]
[TestCase(ManagerPath.SyncAdapterMonitoredNode)]
[TestCase(ManagerPath.SyncAdapterSamplingGroup)]
public async Task DetachAndRestorePreserveItemIdentityAndOwnershipAsync(ManagerPath path)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
using (ManagerOwner owner = CreateManager(server.Object, path))
{
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("Lifecycle", owner.NamespaceIndex),
BrowseName = new QualifiedName("Lifecycle", owner.NamespaceIndex),
DataType = DataTypeIds.Int32,
Value = 42
};
node.CreateAsPredefinedNode(owner.SystemContext);
await owner.AddNodeAsync(node).ConfigureAwait(false);
Mock<IAsyncNodeManager> originalNodeManager = new();
object originalHandle = new();
using MonitoredItem item = CreateMonitoredItem(
server.Object,
originalNodeManager.Object,
originalHandle,
node.NodeId);
var itemLifecycle = (IDetachableMonitoredItem)item;
itemLifecycle.Detach(server.Object);
uint id = item.Id;
ServiceResult attachResult = await owner.Lifecycle
.AttachMonitoredItemAsync(item)
.ConfigureAwait(false);
IReadOnlyList<IMonitoredItem> attached = await owner.Lifecycle
.GetMonitoredItemsSnapshotAsync([node.NodeId])
.ConfigureAwait(false);
object attachedHandle = item.ManagerHandle;
bool ownedAfterAttach = owner.Owns(item.NodeManager);
ServiceResult detachResult = await owner.Lifecycle
.DetachMonitoredItemAsync(item)
.ConfigureAwait(false);
IReadOnlyList<IMonitoredItem> detached = await owner.Lifecycle
.GetMonitoredItemsSnapshotAsync()
.ConfigureAwait(false);
bool wasDetached = itemLifecycle.IsDetached;
IAsyncNodeManager detachedOwner = item.NodeManager;
object detachedHandle = item.ManagerHandle;
Assert.DoesNotThrow(() =>
item.QueueValue(
new DataValue(new Variant(84), StatusCodes.Good),
ServiceResult.Good));
ServiceResult restoreResult = await owner.Lifecycle
.RecoverMonitoredItemAsync(item)
.ConfigureAwait(false);
IReadOnlyList<IMonitoredItem> restored = await owner.Lifecycle
.GetMonitoredItemsSnapshotAsync([node.NodeId])
.ConfigureAwait(false);
object restoredHandle = item.ManagerHandle;
bool wasAttachedAfterRestore = !itemLifecycle.IsDetached;
bool ownedAfterRestore = owner.Owns(item.NodeManager);
await owner.Lifecycle.DetachMonitoredItemAsync(item).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(attachResult.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(attached, Has.Count.EqualTo(1));
Assert.That(attached[0], Is.SameAs(item));
Assert.That(item.Id, Is.EqualTo(id));
Assert.That(attachedHandle, Is.TypeOf<NodeHandle>());
Assert.That(attachedHandle, Is.Not.SameAs(originalHandle));
Assert.That(ownedAfterAttach, Is.True);
Assert.That(detachResult.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(detached, Is.Empty);
Assert.That(wasDetached, Is.True);
Assert.That(
detachedOwner,
Is.SameAs(MonitoredItem.GetDetachedOwner(server.Object)));
Assert.That(owner.Owns(detachedOwner), Is.False);
Assert.That(detachedHandle, Is.SameAs(MonitoredItem.DetachedHandle));
Assert.That(detachedHandle, Is.Not.TypeOf<NodeHandle>());
Assert.That(restoreResult.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(restored, Has.Count.EqualTo(1));
Assert.That(restored[0], Is.SameAs(item));
Assert.That(item.Id, Is.EqualTo(id));
Assert.That(wasAttachedAfterRestore, Is.True);
Assert.That(ownedAfterRestore, Is.True);
Assert.That(restoredHandle, Is.TypeOf<NodeHandle>());
Assert.That(restoredHandle, Is.Not.SameAs(attachedHandle));
});
}
}
[Test]
public void SamplingGroupDetachStopsAndAttachRestartsMonitoring()
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
{
var nodeManager = new Mock<IAsyncNodeManager>();
using var samplingGroups = new TrackingSamplingGroupManager(
server.Object,
nodeManager.Object);
using var manager = new SamplingGroupMonitoredItemManager(
nodeManager.Object,
server.Object,
samplingGroups);
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("SamplingLifecycle", 1),
DataType = DataTypeIds.Int32,
Value = 1
};
var handle = new NodeHandle(node.NodeId, node);
using MonitoredItem item = CreateMonitoredItem(
server.Object,
nodeManager.Object,
new object(),
node.NodeId);
var itemLifecycle = (IDetachableMonitoredItem)item;
itemLifecycle.Detach(server.Object);
var lifecycle = (IMonitoredItemManagerLifecycle)manager;
ServerSystemContext context = server.Object.DefaultSystemContext.Copy(
new OperationContext(item));
(ServiceResult firstAttach, bool firstChanged) = lifecycle.AttachMonitoredItem(
context,
handle,
item,
static (_, _, nodeToCache) => nodeToCache,
static (_, _) => { });
(ServiceResult detach, bool detachChanged) = lifecycle.DetachMonitoredItem(
context,
item,
static (_, _) => { });
(ServiceResult secondAttach, bool secondChanged) = lifecycle.AttachMonitoredItem(
context,
new NodeHandle(node.NodeId, node),
item,
static (_, _, nodeToCache) => nodeToCache,
static (_, _) => { });
Assert.Multiple(() =>
{
Assert.That(firstAttach.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(firstChanged, Is.True);
Assert.That(detach.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(detachChanged, Is.True);
Assert.That(secondAttach.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(secondChanged, Is.True);
Assert.That(samplingGroups.StartCount, Is.EqualTo(2));
Assert.That(samplingGroups.StopCount, Is.EqualTo(1));
Assert.That(samplingGroups.ApplyCount, Is.EqualTo(3));
Assert.That(manager.MonitoredItems[item.Id], Is.SameAs(item));
Assert.That(itemLifecycle.IsDetached, Is.False);
});
lifecycle.DetachMonitoredItem(context, item, static (_, _) => { });
}
}
[Test]
public void MonitoredNodeRebindMovesCallbacksToReplacementNode()
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
{
var originalManager = new Mock<IAsyncNodeManager>();
var replacementManager = new Mock<IAsyncNodeManager>();
var nodeId = new NodeId("RebindCallbacks", 1);
var originalNode = new BaseDataVariableState(null) { NodeId = nodeId };
var replacementNode = new BaseDataVariableState(null) { NodeId = nodeId };
var item = new Mock<IDataChangeMonitoredItem2>();
item.SetupGet(value => value.Id).Returns(1);
using var monitoredNode = new MonitoredNode2(
originalManager.Object,
server.Object,
originalNode);
monitoredNode.Add(item.Object);
ServiceResult result = monitoredNode.Rebind(
replacementManager.Object,
replacementNode);
Assert.Multiple(() =>
{
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(originalNode.OnStateChangedAsync, Is.Null);
Assert.That(replacementNode.OnStateChangedAsync, Is.Not.Null);
Assert.That(monitoredNode.Node, Is.SameAs(replacementNode));
Assert.That(monitoredNode.NodeManager, Is.SameAs(replacementManager.Object));
});
monitoredNode.Remove(item.Object);
}
}
[Test]
public async Task MonitoredNodeDeletedMaskDoesNotClaimManagerDetachmentAsync()
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
{
var nodeManager = new Mock<IAsyncNodeManager>();
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("DeletedMask", 1),
DataType = DataTypeIds.Int32,
Value = 1
};
using MonitoredItem item = CreateMonitoredItem(
server.Object,
nodeManager.Object,
new object(),
node.NodeId);
using var monitoredNode = new MonitoredNode2(
nodeManager.Object,
server.Object,
node);
monitoredNode.Add((IDataChangeMonitoredItem2)item);
await monitoredNode.OnMonitoredNodeChangedAsync(
server.Object.DefaultSystemContext,
node,
NodeStateChangeMasks.Deleted).ConfigureAwait(false);
var lifecycle = (IDetachableMonitoredItem)item;
Assert.Multiple(() =>
{
Assert.That(lifecycle.IsDeleted, Is.True);
Assert.That(lifecycle.IsDetached, Is.False);
});
monitoredNode.Remove((IDataChangeMonitoredItem2)item);
}
}
[TestCase(ManagerPath.AsyncMonitoredNode)]
[TestCase(ManagerPath.AsyncSamplingGroup)]
[TestCase(ManagerPath.SyncAdapterMonitoredNode)]
[TestCase(ManagerPath.SyncAdapterSamplingGroup)]
public async Task DeletedFallbackItemStillDetachesFromOwningManagerAsync(ManagerPath path)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
using (ManagerOwner owner = CreateManager(server.Object, path))
{
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("DeletedFallback", owner.NamespaceIndex),
BrowseName = new QualifiedName("DeletedFallback", owner.NamespaceIndex),
DataType = DataTypeIds.Int32,
Value = 1
};
node.CreateAsPredefinedNode(owner.SystemContext);
await owner.AddNodeAsync(node).ConfigureAwait(false);
using MonitoredItem item = CreateMonitoredItem(
server.Object,
new Mock<IAsyncNodeManager>().Object,
new object(),
node.NodeId);
var lifecycle = (IDetachableMonitoredItem)item;
lifecycle.Detach(server.Object);
Assert.That(
(await owner.Lifecycle.AttachMonitoredItemAsync(item).ConfigureAwait(false))
.StatusCode,
Is.EqualTo(StatusCodes.Good));
lifecycle.MarkNodeDeleted();
ServiceResult detachResult = await owner.Lifecycle
.DetachMonitoredItemAsync(item)
.ConfigureAwait(false);
IReadOnlyList<IMonitoredItem> remaining = await owner.Lifecycle
.GetMonitoredItemsSnapshotAsync()
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(detachResult.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(lifecycle.IsDeleted, Is.True);
Assert.That(lifecycle.IsDetached, Is.True);
Assert.That(remaining, Is.Empty);
});
}
}
[TestCase(ManagerPath.AsyncMonitoredNode)]
[TestCase(ManagerPath.AsyncSamplingGroup)]
[TestCase(ManagerPath.SyncAdapterMonitoredNode)]
[TestCase(ManagerPath.SyncAdapterSamplingGroup)]
public async Task EventAttachCallbackFailureRestoresDetachedStateAsync(ManagerPath path)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
using (ManagerOwner owner = CreateManager(server.Object, path))
{
var node = new BaseObjectState(null)
{
NodeId = new NodeId("EventAttachFailure", owner.NamespaceIndex),
BrowseName = new QualifiedName("EventAttachFailure", owner.NamespaceIndex),
EventNotifier = EventNotifiers.SubscribeToEvents
};
node.CreateAsPredefinedNode(owner.SystemContext);
await owner.AddNodeAsync(node).ConfigureAwait(false);
using MonitoredItem item = CreateEventMonitoredItem(
server.Object,
new Mock<IAsyncNodeManager>().Object,
new object(),
node.NodeId);
var lifecycle = (IDetachableMonitoredItem)item;
lifecycle.Detach(server.Object);
bool throwOnAttach = true;
owner.SetEventSubscriptionCallback(unsubscribe =>
{
if (!unsubscribe && throwOnAttach)
{
throwOnAttach = false;
throw new InvalidOperationException("Injected attach callback failure.");
}
});
Assert.That(
async () => await owner.Lifecycle
.AttachMonitoredItemAsync(item)
.ConfigureAwait(false),
Throws.TypeOf<InvalidOperationException>());
IReadOnlyList<IMonitoredItem> remaining = await owner.Lifecycle
.GetMonitoredItemsSnapshotAsync()
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(lifecycle.IsDetached, Is.True);
Assert.That(remaining, Is.Empty);
Assert.That(node.AreEventsMonitored, Is.False);
Assert.That(
item.NodeManager,
Is.SameAs(MonitoredItem.GetDetachedOwner(server.Object)));
Assert.That(
item.ManagerHandle,
Is.SameAs(MonitoredItem.DetachedHandle));
});
}
}
[TestCase(ManagerPath.AsyncMonitoredNode)]
[TestCase(ManagerPath.AsyncSamplingGroup)]
[TestCase(ManagerPath.SyncAdapterMonitoredNode)]
[TestCase(ManagerPath.SyncAdapterSamplingGroup)]
public async Task EventDetachCallbackFailureRestoresManagerOwnershipAsync(ManagerPath path)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
using (ManagerOwner owner = CreateManager(server.Object, path))
{
var node = new BaseObjectState(null)
{
NodeId = new NodeId("EventDetachFailure", owner.NamespaceIndex),
BrowseName = new QualifiedName("EventDetachFailure", owner.NamespaceIndex),
EventNotifier = EventNotifiers.SubscribeToEvents
};
node.CreateAsPredefinedNode(owner.SystemContext);
await owner.AddNodeAsync(node).ConfigureAwait(false);
using MonitoredItem item = CreateEventMonitoredItem(
server.Object,
new Mock<IAsyncNodeManager>().Object,
new object(),
node.NodeId);
var lifecycle = (IDetachableMonitoredItem)item;
lifecycle.Detach(server.Object);
Assert.That(
(await owner.Lifecycle.AttachMonitoredItemAsync(item).ConfigureAwait(false))
.StatusCode,
Is.EqualTo(StatusCodes.Good));
bool throwOnDetach = true;
owner.SetEventSubscriptionCallback(unsubscribe =>
{
if (unsubscribe && throwOnDetach)
{
throwOnDetach = false;
throw new InvalidOperationException("Injected detach callback failure.");
}
});
Assert.That(
async () => await owner.Lifecycle
.DetachMonitoredItemAsync(item)
.ConfigureAwait(false),
Throws.TypeOf<InvalidOperationException>());
IReadOnlyList<IMonitoredItem> remaining = await owner.Lifecycle
.GetMonitoredItemsSnapshotAsync()
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(lifecycle.IsDetached, Is.False);
Assert.That(remaining, Has.Count.EqualTo(1));
Assert.That(remaining[0], Is.SameAs(item));
Assert.That(node.AreEventsMonitored, Is.True);
});
owner.SetEventSubscriptionCallback(static _ => { });
await owner.Lifecycle.DetachMonitoredItemAsync(item).ConfigureAwait(false);
}
}
[TestCase(ManagerPath.AsyncMonitoredNode)]
[TestCase(ManagerPath.AsyncSamplingGroup)]
public Task EventAttachmentReadinessDoesNotBlockUnrelatedMonitoredItemServiceAsync(ManagerPath path)
{
return AssertEventReadinessDoesNotBlockDataChangeCreationAsync(path, compensateDetach: false);
}
[TestCase(ManagerPath.AsyncMonitoredNode)]
[TestCase(ManagerPath.AsyncSamplingGroup)]
public Task FailedEventDetachCompensationDoesNotBlockUnrelatedMonitoredItemServiceAsync(ManagerPath path)
{
return AssertEventReadinessDoesNotBlockDataChangeCreationAsync(path, compensateDetach: true);
}
[TestCase(ManagerPath.AsyncMonitoredNode)]
[TestCase(ManagerPath.AsyncSamplingGroup)]
public Task FailedEventNodeDeletionCompensationDoesNotBlockUnrelatedMonitoredItemServiceAsync(ManagerPath path)
{
return AssertEventReadinessDoesNotBlockDataChangeCreationAsync(
path, compensateDetach: true, deleteNode: true);
}
[TestCase(ManagerPath.AsyncMonitoredNode)]
[TestCase(ManagerPath.AsyncSamplingGroup)]
public Task FailedEventAttachmentDoesNotRollBackReattachedItemAsync(ManagerPath path)
{
return AssertStaleEventTransitionPreservesReplacementAsync(path, failAttachment: true);
}
[TestCase(ManagerPath.AsyncMonitoredNode)]
[TestCase(ManagerPath.AsyncSamplingGroup)]
public Task CompletedEventDetachDoesNotParkReattachedItemAsync(ManagerPath path)
{
return AssertStaleEventTransitionPreservesReplacementAsync(path, failAttachment: false);
}
[TestCase(ManagerPath.AsyncMonitoredNode)]
[TestCase(ManagerPath.AsyncSamplingGroup)]
[TestCase(ManagerPath.SyncAdapterMonitoredNode)]
[TestCase(ManagerPath.SyncAdapterSamplingGroup)]
public async Task NodeRemovalFailureAfterAddressSpaceRemovalKeepsItemDeletedAsync(
ManagerPath path)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
using (ManagerOwner owner = CreateManager(server.Object, path))
{
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("RemovalFailure", owner.NamespaceIndex),
BrowseName = new QualifiedName("RemovalFailure", owner.NamespaceIndex),
DataType = DataTypeIds.Int32,
Value = 1
};
node.CreateAsPredefinedNode(owner.SystemContext);
await owner.AddNodeAsync(node).ConfigureAwait(false);
using MonitoredItem item = CreateMonitoredItem(
server.Object,
new Mock<IAsyncNodeManager>().Object,
new object(),
node.NodeId);
var lifecycle = (IDetachableMonitoredItem)item;
lifecycle.Detach(server.Object);
Assert.That(
(await owner.Lifecycle.AttachMonitoredItemAsync(item).ConfigureAwait(false))
.StatusCode,
Is.EqualTo(StatusCodes.Good));
owner.SetNodeRemovedCallback(static _ =>
throw new InvalidOperationException("Injected removal callback failure."));
Assert.That(
async () => await owner.DeleteNodeAsync(node.NodeId).ConfigureAwait(false),
Throws.TypeOf<InvalidOperationException>()
.With.Message.EqualTo("Injected removal callback failure."));
IReadOnlyList<IMonitoredItem> remaining = await owner.Lifecycle
.GetMonitoredItemsSnapshotAsync()
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(owner.ContainsNode(node.NodeId), Is.False);
Assert.That(lifecycle.IsDeleted, Is.True);
Assert.That(lifecycle.IsDetached, Is.True);
Assert.That(remaining, Is.Empty);
});
}
}
[Test]
public async Task DurableStartupFallbackCanBeDeletedAndLaterRecoveredAsync()
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
{
bool isRunning = false;
server.SetupGet(value => value.IsRunning).Returns(() => isRunning);
var durableQueueFactory = new Mock<IMonitoredItemQueueFactory>();
durableQueueFactory.SetupGet(value => value.SupportsDurableQueues).Returns(true);
durableQueueFactory
.Setup(value => value.CreateDataChangeQueue(
It.IsAny<bool>(),
It.IsAny<uint>()))
.Returns<bool, uint>((_, id) =>
queueFactory.CreateDataChangeQueue(false, id));
server.SetupGet(value => value.MonitoredItemQueueFactory)
.Returns(durableQueueFactory.Object);
var configurationManager = new Mock<IConfigurationNodeManager>();
var coreManager = new Mock<ICoreNodeManager>();
var mainFactory = new Mock<IMainNodeManagerFactory>();
mainFactory.Setup(value => value.CreateConfigurationNodeManager())
.Returns(configurationManager.Object);
mainFactory.Setup(value => value.CreateCoreNodeManager(It.IsAny<ushort>()))
.Returns(coreManager.Object);
server.SetupGet(value => value.MainNodeManagerFactory).Returns(mainFactory.Object);
ApplicationConfiguration configuration = CreateConfiguration();
var recoverableManager = new TestableAsyncCustomNodeManager(
server.Object,
configuration,
useSamplingGroups: false,
new Mock<ILogger>().Object,
DeterministicServerMock.TestNamespaceUri);
using var master = new MasterNodeManager(
server.Object,
configuration,
null,
[recoverableManager],
null);
server.SetupGet(value => value.NodeManager).Returns(master);
var recoverNodeId = new NodeId("DurableRecover", recoverableManager.NamespaceIndex);
var deleteNodeId = new NodeId("DurableDelete", recoverableManager.NamespaceIndex);
var storedItems = new List<IStoredMonitoredItem>
{
CreateStoredMonitoredItem(11, recoverNodeId),
CreateStoredMonitoredItem(12, deleteNodeId)
};
var restoredItems = new List<IMonitoredItem> { null!, null! };
await master.RestoreMonitoredItemsAsync(
storedItems,
restoredItems,
new Mock<IUserIdentity>().Object,
CancellationToken.None).ConfigureAwait(false);
using IMonitoredItem recoverableItem = restoredItems[0];
using IMonitoredItem deletableItem = restoredItems[1];
var recoverableLifecycle = (IDetachableMonitoredItem)recoverableItem;
var deletableLifecycle = (IDetachableMonitoredItem)deletableItem;
Assert.Multiple(() =>
{
Assert.That(recoverableLifecycle.IsDetached, Is.True);
Assert.That(recoverableLifecycle.IsDeleted, Is.True);
Assert.That(
recoverableItem.NodeManager,
Is.SameAs(coreManager.Object));
Assert.That(
recoverableItem.ManagerHandle,
Is.SameAs(MonitoredItem.DetachedHandle));
});
var deleteErrors = new List<ServiceResult> { ServiceResult.Good };
await master.DeleteMonitoredItemsAsync(
new OperationContext(deletableItem),
subscriptionId: 1,
[deletableItem],
deleteErrors,
CancellationToken.None).ConfigureAwait(false);
var node = new BaseDataVariableState(null)
{
NodeId = recoverNodeId,
BrowseName = new QualifiedName("DurableRecover", recoverableManager.NamespaceIndex),
DataType = DataTypeIds.Int32,
Value = 42
};
node.CreateAsPredefinedNode(recoverableManager.SystemContext);
await recoverableManager.AddPredefinedNodePublicAsync(
recoverableManager.SystemContext,
node).ConfigureAwait(false);
isRunning = true;
await master.RecoverMonitoredItemsAsync(
recoverableManager,
[recoverableItem],
CancellationToken.None).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(deletableLifecycle.IsDetached, Is.True);
Assert.That(deletableLifecycle.IsDeleted, Is.True);
Assert.That(deleteErrors[0].StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(recoverableLifecycle.IsDetached, Is.False);
Assert.That(recoverableLifecycle.IsDeleted, Is.False);
Assert.That(recoverableItem.NodeManager, Is.SameAs(recoverableManager));
Assert.That(
recoverableManager.MonitoredItems[recoverableItem.Id],
Is.SameAs(recoverableItem));
});
await ((INodeManagerMonitoredItemLifecycle)recoverableManager)
.DetachMonitoredItemAsync(recoverableItem)
.ConfigureAwait(false);
}
}
[Test]
public async Task RollbackDoesNotRestoreItemRemovedFromItsSubscriptionAsync()
{
bool subscriptionOwnsItem = true;
var item = new Mock<IMonitoredItem>();
var current = new TestNodeManagerLifecycle();
var server = new Mock<IServerInternal>();
var transition = new NodeManagerLifecycle.MonitoredItemTransition(
server.Object,
current,
replacement: null,
compatibleItems: [],
deletedItems: [item.Object],
isOwnedBySubscription: _ => subscriptionOwnsItem);
await transition.DetachCurrentAsync(CancellationToken.None).ConfigureAwait(false);
subscriptionOwnsItem = false;
await transition.RollbackAsync(CancellationToken.None).ConfigureAwait(false);
Assert.That(current.RestoreCount, Is.Zero);
}
[Test]
public async Task ReloadTransitionAttachesOnlyAfterTheCommitBoundaryAsync()
{
bool committed = false;
var item = new Mock<IMonitoredItem>();
var current = new TestNodeManagerLifecycle();
var replacement = new TestNodeManagerLifecycle
{
AttachCallback = _ =>
{
Assert.That(committed, Is.True);
return ServiceResult.Good;
}
};
var server = new Mock<IServerInternal>();
var transition = new NodeManagerLifecycle.MonitoredItemTransition(
server.Object,
current,
replacement,
compatibleItems: [item.Object],
deletedItems: [],
isOwnedBySubscription: static _ => true);
await transition.DetachCurrentAsync(CancellationToken.None).ConfigureAwait(false);
Assert.That(replacement.AttachCount, Is.Zero);
committed = true;
List<Exception> failures = await transition
.AttachCompatibleAsync(CancellationToken.None)
.ConfigureAwait(false);
Assert.That(failures, Is.Empty);
Assert.That(replacement.AttachCount, Is.EqualTo(1));
}
[Test]
public async Task PostCommitAttachFailureLeavesItemDetachedAndDeletedAsync()
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
using (MonitoredItem item = CreateMonitoredItem(
server.Object,
new Mock<IAsyncNodeManager>().Object,
new object(),
new NodeId("PostCommitFailure", 1)))
{
var current = new TestNodeManagerLifecycle();
var replacement = new TestNodeManagerLifecycle
{
AttachCallback = _ =>
throw new InvalidOperationException("Injected attach failure.")
};
var transition = new NodeManagerLifecycle.MonitoredItemTransition(
server.Object,
current,
replacement,
compatibleItems: [item],
deletedItems: [],
isOwnedBySubscription: static _ => true);
await transition.DetachCurrentAsync(CancellationToken.None).ConfigureAwait(false);
List<Exception> failures = await transition
.AttachCompatibleAsync(CancellationToken.None)
.ConfigureAwait(false);
transition.MarkDeletedItems();
var itemLifecycle = (IDetachableMonitoredItem)item;
Assert.Multiple(() =>
{
Assert.That(failures, Has.Count.EqualTo(1));
Assert.That(failures[0].Message, Is.EqualTo("Injected attach failure."));
Assert.That(itemLifecycle.IsDetached, Is.True);
Assert.That(itemLifecycle.IsDeleted, Is.True);
});
}
}
[Test]
public async Task TransitionFailureVariantsReportEveryRollbackFailureAsync()
{
var firstItem = new Mock<IMonitoredItem>();
var secondItem = new Mock<IMonitoredItem>();
var current = new TestNodeManagerLifecycle
{
DetachCallback = item => ReferenceEquals(item, firstItem.Object)
? new ServiceResult(StatusCodes.BadInvalidState)
: ServiceResult.Good
};
var server = new Mock<IServerInternal>();
var failedDetachTransition = new NodeManagerLifecycle.MonitoredItemTransition(
server.Object,
current,
replacement: null,
compatibleItems: [firstItem.Object],
deletedItems: [],
isOwnedBySubscription: static _ => true);
ServiceResultException detachException = Assert.ThrowsAsync<ServiceResultException>(
async () => await failedDetachTransition
.DetachCurrentAsync(CancellationToken.None)
.ConfigureAwait(false));
Assert.That(detachException.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
current.DetachCallback = static _ => ServiceResult.Good;
current.RestoreCallback = item => ReferenceEquals(item, firstItem.Object)
? new ServiceResult(StatusCodes.BadUnexpectedError)
: throw new InvalidOperationException("Injected restore failure.");
var replacement = new TestNodeManagerLifecycle
{
AttachCallback = static _ => ServiceResult.Good,
DetachCallback = item => ReferenceEquals(item, firstItem.Object)
? new ServiceResult(StatusCodes.BadInvalidState)
: throw new InvalidOperationException("Injected replacement detach failure.")
};
var rollbackTransition = new NodeManagerLifecycle.MonitoredItemTransition(
server.Object,
current,
replacement,
compatibleItems: [firstItem.Object, secondItem.Object],
deletedItems: [],
isOwnedBySubscription: static _ => true);
await rollbackTransition.DetachCurrentAsync(CancellationToken.None).ConfigureAwait(false);
Assert.That(
await rollbackTransition
.AttachCompatibleAsync(CancellationToken.None)
.ConfigureAwait(false),
Is.Empty);
AggregateException rollbackException = Assert.ThrowsAsync<AggregateException>(
async () => await rollbackTransition
.RollbackAsync(CancellationToken.None)
.ConfigureAwait(false));
Assert.Multiple(() =>
{
Assert.That(rollbackException.InnerExceptions, Has.Count.EqualTo(4));
Assert.That(
rollbackException.InnerExceptions,
Has.Exactly(2).TypeOf<ServiceResultException>());
Assert.That(
rollbackException.InnerExceptions,
Has.Exactly(2).TypeOf<InvalidOperationException>());
});
}
[TestCase(false, 0)]
[TestCase(true, 1)]
public async Task TransitionAttachFailureClassifiesExpectedStatusAsync(
bool unexpected,
int expectedFailureCount)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
using (MonitoredItem item = CreateMonitoredItem(
server.Object,
new Mock<IAsyncNodeManager>().Object,
new object(),
new NodeId("AttachClassification", 1)))
{
var current = new TestNodeManagerLifecycle();
var replacement = new TestNodeManagerLifecycle
{
AttachCallback = _ => new ServiceResult(
unexpected
? StatusCodes.BadUnexpectedError
: StatusCodes.BadNodeIdUnknown)
};
var transition = new NodeManagerLifecycle.MonitoredItemTransition(
server.Object,
current,
replacement,
compatibleItems: [item],
deletedItems: [],
isOwnedBySubscription: static _ => true);
await transition.DetachCurrentAsync(CancellationToken.None).ConfigureAwait(false);
List<Exception> failures = await transition
.AttachCompatibleAsync(CancellationToken.None)
.ConfigureAwait(false);
transition.MarkDeletedItems();
Assert.Multiple(() =>
{
Assert.That(failures, Has.Count.EqualTo(expectedFailureCount));
Assert.That(((IDetachableMonitoredItem)item).IsDetached, Is.True);
Assert.That(((IDetachableMonitoredItem)item).IsDeleted, Is.True);
});
}
}
[Test]
public async Task TransitionWithoutSubscriptionOwnershipSkipsRecoveryAsync()
{
var item = new Mock<IMonitoredItem>();
var subscriptionManager = new Mock<ISubscriptionManager>();
subscriptionManager.Setup(manager => manager.GetSubscriptions()).Returns([]);
var server = new Mock<IServerInternal>();
server.SetupGet(value => value.SubscriptionManager)
.Returns(subscriptionManager.Object);
var current = new TestNodeManagerLifecycle();
var replacement = new TestNodeManagerLifecycle();
var transition = new NodeManagerLifecycle.MonitoredItemTransition(
server.Object,
current,
replacement,
compatibleItems: [item.Object],
deletedItems: []);
await transition.DetachCurrentAsync(CancellationToken.None).ConfigureAwait(false);
Assert.That(
await transition
.AttachCompatibleAsync(CancellationToken.None)
.ConfigureAwait(false),
Is.Empty);
await transition.RollbackAsync(CancellationToken.None).ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(replacement.AttachCount, Is.Zero);
Assert.That(current.RestoreCount, Is.Zero);
});
}
[TestCase(false)]
[TestCase(true)]
public void ManagerLifecycleHandlesIdempotenceAndConflictingOwnership(bool useSamplingGroups)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
{
var nodeManager = new Mock<IAsyncNodeManager>();
using TrackingSamplingGroupManager samplingGroups = (useSamplingGroups
? new TrackingSamplingGroupManager(server.Object, nodeManager.Object)
: null)!;
using IMonitoredItemManager manager = useSamplingGroups
? new SamplingGroupMonitoredItemManager(
nodeManager.Object,
server.Object,
samplingGroups!)
: new MonitoredNodeMonitoredItemManager(nodeManager.Object, server.Object);
var lifecycle = (IMonitoredItemManagerLifecycle)manager;
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("Idempotent", 1),
DataType = DataTypeIds.Int32,
Value = 1
};
var handle = new NodeHandle(node.NodeId, node);
using MonitoredItem item = CreateMonitoredItem(
server.Object,
nodeManager.Object,
new object(),
node.NodeId);
using MonitoredItem conflictingItem = CreateMonitoredItem(
server.Object,
nodeManager.Object,
new object(),
node.NodeId);
var itemLifecycle = (IDetachableMonitoredItem)item;
itemLifecycle.Detach(server.Object);
((IDetachableMonitoredItem)conflictingItem).Detach(server.Object);
ServerSystemContext context = server.Object.DefaultSystemContext.Copy(
new OperationContext(item));
(ServiceResult firstAttach, bool firstChanged) = lifecycle.AttachMonitoredItem(
context,
handle,
item,
static (_, _, nodeToCache) => nodeToCache,
static (_, _) => { });
(ServiceResult repeatedAttach, bool repeatedChanged) = lifecycle.AttachMonitoredItem(
context,
handle,
item,
static (_, _, nodeToCache) => nodeToCache,
static (_, _) => { });
(ServiceResult conflictingAttach, bool conflictingChanged) =
lifecycle.AttachMonitoredItem(
context,
new NodeHandle(node.NodeId, node),
conflictingItem,
static (_, _, nodeToCache) => nodeToCache,
static (_, _) => { });
itemLifecycle.Detach(server.Object);
(ServiceResult detachedExistingAttach, bool detachedExistingChanged) =
lifecycle.AttachMonitoredItem(
context,
handle,
item,
static (_, _, nodeToCache) => nodeToCache,
static (_, _) => { });
itemLifecycle.Rebind(nodeManager.Object, handle);
(ServiceResult detach, bool detachChanged) = lifecycle.DetachMonitoredItem(
context,
item,
static (_, _) => { });
(ServiceResult repeatedDetach, bool repeatedDetachChanged) =
lifecycle.DetachMonitoredItem(
context,
item,
static (_, _) => { });
Assert.Multiple(() =>
{
Assert.That(firstAttach.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(firstChanged, Is.True);
Assert.That(repeatedAttach.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(repeatedChanged, Is.False);
Assert.That(
conflictingAttach.StatusCode,
Is.EqualTo(StatusCodes.BadMonitoredItemIdInvalid));
Assert.That(conflictingChanged, Is.False);
Assert.That(
detachedExistingAttach.StatusCode,
Is.EqualTo(StatusCodes.BadInvalidState));
Assert.That(detachedExistingChanged, Is.False);
Assert.That(detach.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(detachChanged, Is.True);
Assert.That(repeatedDetach.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(repeatedDetachChanged, Is.False);
});
}
}
[TestCase(false)]
[TestCase(true)]
public void ManagerLifecycleRejectsUnsupportedItemsAndEmptySnapshots(bool useSamplingGroups)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
{
var nodeManager = new Mock<IAsyncNodeManager>();
using TrackingSamplingGroupManager samplingGroups = (useSamplingGroups
? new TrackingSamplingGroupManager(server.Object, nodeManager.Object)
: null)!;
using IMonitoredItemManager manager = useSamplingGroups
? new SamplingGroupMonitoredItemManager(
nodeManager.Object,
server.Object,
samplingGroups!)
: new MonitoredNodeMonitoredItemManager(nodeManager.Object, server.Object);
var lifecycle = (IMonitoredItemManagerLifecycle)manager;
var unsupported = new Mock<ISampledDataChangeMonitoredItem>();
unsupported.SetupGet(value => value.Id).Returns(55);
unsupported.SetupGet(value => value.NodeId).Returns(new NodeId("Unsupported", 1));
var node = new BaseDataVariableState(null)
{
NodeId = unsupported.Object.NodeId
};
(ServiceResult detach, bool detachChanged) = lifecycle.DetachMonitoredItem(
server.Object.DefaultSystemContext,
unsupported.Object,
static (_, _) => { });
(ServiceResult attach, bool attachChanged) = lifecycle.AttachMonitoredItem(
server.Object.DefaultSystemContext,
new NodeHandle(node.NodeId, node),
unsupported.Object,
static (_, _, nodeToCache) => nodeToCache,
static (_, _) => { });
Assert.Multiple(() =>
{
Assert.That(detach.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported));
Assert.That(detachChanged, Is.False);
Assert.That(attach.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported));
Assert.That(attachChanged, Is.False);
Assert.That(lifecycle.GetMonitoredItemsSnapshot([]), Is.Empty);
});
}
}
[Test]
public void MonitoredNodeAttachCacheConflictCleansPartialOwnership()
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
{
var nodeManager = new Mock<IAsyncNodeManager>();
using var manager = new MonitoredNodeMonitoredItemManager(
nodeManager.Object,
server.Object);
var lifecycle = (IMonitoredItemManagerLifecycle)manager;
var cachedNode = new BaseDataVariableState(null)
{
NodeId = new NodeId("CacheConflict", 1),
DataType = DataTypeIds.Int32,
Value = 1
};
using MonitoredItem existingItem = CreateMonitoredItem(
server.Object,
nodeManager.Object,
new object(),
cachedNode.NodeId,
id: 2);
((IDetachableMonitoredItem)existingItem).Detach(server.Object);
var existingHandle = new NodeHandle(cachedNode.NodeId, cachedNode);
Assert.That(
lifecycle.AttachMonitoredItem(
server.Object.DefaultSystemContext,
existingHandle,
existingItem,
static (_, _, nodeToCache) => nodeToCache,
static (_, _) => { }).Result.StatusCode,
Is.EqualTo(StatusCodes.Good));
var freshNode = new BaseDataVariableState(null)
{
NodeId = cachedNode.NodeId,
DataType = DataTypeIds.Int32,
Value = 2
};
using MonitoredItem conflictingItem = CreateMonitoredItem(
server.Object,
nodeManager.Object,
new object(),
freshNode.NodeId,
id: 3);
((IDetachableMonitoredItem)conflictingItem).Detach(server.Object);
var conflictingHandle = new NodeHandle(freshNode.NodeId, freshNode);
int removeCacheCount = 0;
(ServiceResult result, bool changed) = lifecycle.AttachMonitoredItem(
server.Object.DefaultSystemContext,
conflictingHandle,
conflictingItem,
(_, _, _) => cachedNode,
(_, _) => removeCacheCount++);
Assert.Multiple(() =>
{
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
Assert.That(changed, Is.False);
Assert.That(removeCacheCount, Is.EqualTo(1));
Assert.That(manager.MonitoredItems.ContainsKey(conflictingItem.Id), Is.False);
Assert.That(
lifecycle.GetMonitoredItemsSnapshot(null),
Is.EqualTo([existingItem]));
});
lifecycle.DetachMonitoredItem(
server.Object.DefaultSystemContext,
existingItem,
static (_, _) => { });
}
}
[Test]
public void SamplingGroupAttachApplyFailureStopsMonitoringAndRestoresDetachedOwnership()
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
{
var nodeManager = new Mock<IAsyncNodeManager>();
using var samplingGroups = new TrackingSamplingGroupManager(
server.Object,
nodeManager.Object)
{
FailNextApply = true
};
using var manager = new SamplingGroupMonitoredItemManager(
nodeManager.Object,
server.Object,
samplingGroups);
var lifecycle = (IMonitoredItemManagerLifecycle)manager;
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("ApplyFailure", 1),
DataType = DataTypeIds.Int32,
Value = 1
};
using MonitoredItem item = CreateMonitoredItem(
server.Object,
nodeManager.Object,
new object(),
node.NodeId);
var itemLifecycle = (IDetachableMonitoredItem)item;
itemLifecycle.Detach(server.Object);
Assert.That(
() => lifecycle.AttachMonitoredItem(
server.Object.DefaultSystemContext,
new NodeHandle(node.NodeId, node),
item,
static (_, _, nodeToCache) => nodeToCache,
static (_, _) => { }),
Throws.InvalidOperationException.With.Message.EqualTo(
"Injected apply failure."));
Assert.Multiple(() =>
{
Assert.That(samplingGroups.StartCount, Is.EqualTo(1));
Assert.That(samplingGroups.StopCount, Is.EqualTo(1));
Assert.That(samplingGroups.ApplyCount, Is.EqualTo(2));
Assert.That(manager.MonitoredItems, Is.Empty);
Assert.That(itemLifecycle.IsDetached, Is.True);
Assert.That(
item.NodeManager,
Is.SameAs(MonitoredItem.GetDetachedOwner(server.Object)));
});
}
}
[TestCase(false)]
[TestCase(true)]
public void RestoreMonitoredItemMarksFoundNodeAsAttached(bool useSamplingGroups)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
{
var nodeManager = new Mock<IAsyncNodeManager>();
using MonitoredItem source = CreateMonitoredItem(
server.Object,
nodeManager.Object,
new object(),
new NodeId("Restored", 1));
((IDetachableMonitoredItem)source).MarkNodeDeleted();
IStoredMonitoredItem stored = source.ToStorableMonitoredItem();
var node = new BaseDataVariableState(null)
{
NodeId = stored.NodeId,
DataType = DataTypeIds.Int32,
Value = 1
};
var handle = new NodeHandle(node.NodeId, node);
IMonitoredItemManager manager;
TrackingSamplingGroupManager? samplingGroups = null;
if (useSamplingGroups)
{
samplingGroups = new TrackingSamplingGroupManager(
server.Object,
nodeManager.Object);
manager = new SamplingGroupMonitoredItemManager(
nodeManager.Object,
server.Object,
samplingGroups);
}
else
{
manager = new MonitoredNodeMonitoredItemManager(
nodeManager.Object,
server.Object);
}
using (manager)
{
bool restored = manager.RestoreMonitoredItem(
server.Object,
nodeManager.Object,
server.Object.DefaultSystemContext,
handle,
stored,
new Mock<IUserIdentity>().Object,
static (_, _, nodeToCache) => nodeToCache,
out ISampledDataChangeMonitoredItem restoredItem);
using ISampledDataChangeMonitoredItem item = restoredItem;
Assert.Multiple(() =>
{
Assert.That(restored, Is.True);
Assert.That(((IDetachableMonitoredItem)item).IsDetached, Is.False);
Assert.That(item.NodeManager, Is.SameAs(nodeManager.Object));
Assert.That(item.ManagerHandle, Is.SameAs(handle));
Assert.That(manager.MonitoredItems[item.Id], Is.SameAs(item));
});
((IMonitoredItemManagerLifecycle)manager).DetachMonitoredItem(
server.Object.DefaultSystemContext,
item,
static (_, _) => { });
}
samplingGroups?.Dispose();
}
}
[TestCase(false)]
[TestCase(true)]
public void CustomAndRestoredItemsAcquireIndependentComponentReferences(bool restore)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
{
var owner = new Mock<IAsyncNodeManager>();
using var manager = new MonitoredNodeMonitoredItemManager(owner.Object, server.Object);
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId("SharedComponent", 1),
DataType = DataTypeIds.Int32,
Value = 1
};
int references = 0;
NodeState AddReference(ISystemContext context, NodeHandle handle, NodeState value)
{
references++;
return value;
}
void RemoveReference(ISystemContext context, NodeHandle handle) => references--;
var items = new List<ISampledDataChangeMonitoredItem>();
var ids = new MonitoredItemIdFactory();
for (uint id = 1; id <= 2; id++)
{
var handle = new NodeHandle(node.NodeId, node);
if (restore)
{
StoredMonitoredItem stored = CreateStoredMonitoredItem(id, node.NodeId);
stored.IsDurable = false;
Assert.That(manager.RestoreMonitoredItem(
server.Object, owner.Object, server.Object.DefaultSystemContext,
handle, stored, new Mock<IUserIdentity>().Object, AddReference,
out ISampledDataChangeMonitoredItem item), Is.True);
items.Add(item);
}
else
{
items.Add(((ICustomMonitoredItemManager)manager).CreateCustomMonitoredItem(
server.Object,
owner.Object,
server.Object.DefaultSystemContext,
handle,
1,
1000,
DiagnosticsMasks.None,
TimestampsToReturn.Both,
new MonitoredItemCreateRequest
{
ItemToMonitor = new ReadValueId { NodeId = node.NodeId, AttributeId = Attributes.Value },
MonitoringMode = MonitoringMode.Reporting,
RequestedParameters = new MonitoringParameters { ClientHandle = 3, QueueSize = 10 }
},
null!,
null!,
1000,
10,
false,
ids,
AddReference,
RemoveReference,
context => CreateMonitoredItem(
context.Server, context.NodeManager, context.Handle,
context.Handle.NodeId, context.MonitoredItemId),
initialValueQueued: false));
}
}
try
{
Assert.That(references, Is.EqualTo(2));
var lifecycle = (IMonitoredItemManagerLifecycle)manager;
Assert.That(ServiceResult.IsGood(lifecycle.DetachMonitoredItem(
server.Object.DefaultSystemContext, items[0], RemoveReference).Result), Is.True);
Assert.That(references, Is.EqualTo(1));
Assert.That(
manager.MonitoredNodes[node.NodeId].DataChangeMonitoredItems.ContainsKey(items[1].Id), Is.True);
Assert.That(ServiceResult.IsGood(lifecycle.DetachMonitoredItem(
server.Object.DefaultSystemContext, items[1], RemoveReference).Result), Is.True);
Assert.That(references, Is.Zero);
Assert.That(manager.MonitoredNodes, Is.Empty);
}
finally
{
foreach (ISampledDataChangeMonitoredItem item in items)
{
item.Dispose();
}
}
}
}
private static async Task AssertStaleEventTransitionPreservesReplacementAsync(
ManagerPath path,
bool failAttachment)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
using (ManagerOwner owner = CreateManager(server.Object, path))
{
var source = new BaseObjectState(null)
{
NodeId = new NodeId("ReattachedEventSource", owner.NamespaceIndex),
BrowseName = new QualifiedName("ReattachedEventSource", owner.NamespaceIndex),
EventNotifier = EventNotifiers.SubscribeToEvents
};
source.CreateAsPredefinedNode(owner.SystemContext);
await owner.AddNodeAsync(source).ConfigureAwait(false);
using MonitoredItem item = CreateEventMonitoredItem(
server.Object, Mock.Of<IAsyncNodeManager>(), new NodeHandle(), source.NodeId);
var lifecycle = (IDetachableMonitoredItem)item;
lifecycle.Detach(server.Object);
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var failure = new InvalidOperationException("Original attachment readiness failed.");
int subscribeCalls = 0;
int unsubscribeCalls = 0;
owner.AsyncManager.EventSubscriptionCallback = async (unsubscribe, ct) =>
{
int call = unsubscribe
? Interlocked.Increment(ref unsubscribeCalls)
: Interlocked.Increment(ref subscribeCalls);
if (call == 1 && unsubscribe != failAttachment)
{
entered.TrySetResult(true);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
if (failAttachment)
{
throw failure;
}
}
};
var transitions = new List<Task<ServiceResult>>();
try
{
if (!failAttachment)
{
Task<ServiceResult> initial = owner.Lifecycle.AttachMonitoredItemAsync(item).AsTask();
transitions.Add(initial);
ServiceResult result = await initial.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
}
Task<ServiceResult> original = failAttachment
? owner.Lifecycle.AttachMonitoredItemAsync(item).AsTask()
: owner.Lifecycle.DetachMonitoredItemAsync(item).AsTask();
transitions.Add(original);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
object originalHandle = item.ManagerHandle;
using (Assert.EnterMultipleScope())
{
Assert.That(original.IsCompleted, Is.False);
Assert.That(originalHandle, Is.TypeOf<NodeHandle>());
Assert.That(source.AreEventsMonitored, Is.EqualTo(failAttachment));
Assert.That(Volatile.Read(ref subscribeCalls), Is.EqualTo(1));
Assert.That(Volatile.Read(ref unsubscribeCalls), Is.EqualTo(failAttachment ? 0 : 1));
}
if (failAttachment)
{
Task<ServiceResult> detach = owner.Lifecycle.DetachMonitoredItemAsync(item).AsTask();
transitions.Add(detach);
ServiceResult result = await detach.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
using (Assert.EnterMultipleScope())
{
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(lifecycle.IsDetached, Is.True);
Assert.That(source.AreEventsMonitored, Is.False);
}
}
Task<ServiceResult> replacement = owner.Lifecycle.AttachMonitoredItemAsync(item).AsTask();
transitions.Add(replacement);
ServiceResult attached = await replacement.WaitAsync(TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
object replacementHandle = item.ManagerHandle;
using (Assert.EnterMultipleScope())
{
Assert.That(attached.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(replacementHandle, Is.TypeOf<NodeHandle>());
Assert.That(replacementHandle, Is.Not.SameAs(originalHandle));
Assert.That(item.NodeManager, Is.SameAs(owner.AsyncManager));
Assert.That(lifecycle.IsDetached, Is.False);
Assert.That(source.AreEventsMonitored, Is.True);
Assert.That(original.IsCompleted, Is.False);
Assert.That(release.Task.IsCompleted, Is.False);
Assert.That(owner.AsyncManager.DetachedNotificationCount,
Is.EqualTo(failAttachment ? 1 : 0));
}
release.TrySetResult(true);
if (failAttachment)
{
InvalidOperationException error = Assert.ThrowsAsync<InvalidOperationException>(
async () => await original.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false))!;
Assert.That(error, Is.SameAs(failure));
}
else
{
ServiceResult detached = await original.WaitAsync(TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
Assert.That(detached.StatusCode, Is.EqualTo(StatusCodes.Good));
}
IReadOnlyList<IMonitoredItem> snapshot = await owner.Lifecycle.GetMonitoredItemsSnapshotAsync()
.AsTask().WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
using (Assert.EnterMultipleScope())
{
Assert.That(snapshot, Has.Count.EqualTo(1));
Assert.That(snapshot[0], Is.SameAs(item));
Assert.That(item.NodeManager, Is.SameAs(owner.AsyncManager));
Assert.That(item.ManagerHandle, Is.SameAs(replacementHandle));
Assert.That(lifecycle.IsDetached, Is.False);
Assert.That(lifecycle.IsDeleted, Is.False);
Assert.That(source.AreEventsMonitored, Is.True);
Assert.That(Volatile.Read(ref subscribeCalls), Is.EqualTo(2));
Assert.That(Volatile.Read(ref unsubscribeCalls), Is.EqualTo(1));
Assert.That(owner.AsyncManager.DetachedNotificationCount,
Is.EqualTo(failAttachment ? 1 : 0));
}
Task<ServiceResult> finalDetach = owner.Lifecycle.DetachMonitoredItemAsync(item).AsTask();
transitions.Add(finalDetach);
ServiceResult finalResult = await finalDetach.WaitAsync(TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
snapshot = await owner.Lifecycle.GetMonitoredItemsSnapshotAsync()
.AsTask().WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
using (Assert.EnterMultipleScope())
{
Assert.That(finalResult.StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(snapshot, Is.Empty);
Assert.That(lifecycle.IsDetached, Is.True);
Assert.That(item.NodeManager, Is.SameAs(MonitoredItem.GetDetachedOwner(server.Object)));
Assert.That(item.ManagerHandle, Is.SameAs(MonitoredItem.DetachedHandle));
Assert.That(source.AreEventsMonitored, Is.False);
Assert.That(Volatile.Read(ref subscribeCalls), Is.EqualTo(2));
Assert.That(Volatile.Read(ref unsubscribeCalls), Is.EqualTo(2));
Assert.That(owner.AsyncManager.DetachedNotificationCount,
Is.EqualTo(failAttachment ? 2 : 1));
}
}
finally
{
release.TrySetResult(true);
foreach (Task<ServiceResult> transition in transitions)
{
try
{
await transition.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
catch (InvalidOperationException exception) when (
failAttachment && ReferenceEquals(exception, failure))
{
}
}
owner.AsyncManager.EventSubscriptionCallback = static (_, _) => default;
await owner.Lifecycle.DetachMonitoredItemAsync(item)
.AsTask().WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
}
}
private static async Task AssertEventReadinessDoesNotBlockDataChangeCreationAsync(
ManagerPath path,
bool compensateDetach,
bool deleteNode = false)
{
var clock = new FakeTimeProvider();
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory, clock);
using (queueFactory)
using (ManagerOwner owner = CreateManager(server.Object, path))
{
var source = new BaseObjectState(null)
{
NodeId = new NodeId("HeldEventSource", owner.NamespaceIndex),
BrowseName = new QualifiedName("HeldEventSource", owner.NamespaceIndex),
EventNotifier = EventNotifiers.SubscribeToEvents
};
var value = new BaseDataVariableState(null)
{
NodeId = new NodeId("IndependentValue", owner.NamespaceIndex),
BrowseName = new QualifiedName("IndependentValue", owner.NamespaceIndex),
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.Scalar,
AccessLevel = AccessLevels.CurrentRead,
UserAccessLevel = AccessLevels.CurrentRead,
Value = 42
};
source.CreateAsPredefinedNode(owner.SystemContext);
value.CreateAsPredefinedNode(owner.SystemContext);
await owner.AddNodeAsync(source).ConfigureAwait(false);
await owner.AddNodeAsync(value).ConfigureAwait(false);
using MonitoredItem eventItem = CreateEventMonitoredItem(
server.Object, Mock.Of<IAsyncNodeManager>(), new NodeHandle(), source.NodeId);
var lifecycle = (IDetachableMonitoredItem)eventItem;
lifecycle.Detach(server.Object);
if (compensateDetach)
{
ServiceResult attached = await owner.Lifecycle.AttachMonitoredItemAsync(eventItem)
.ConfigureAwait(false);
Assert.That(attached.StatusCode, Is.EqualTo(StatusCodes.Good));
}
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var failure = new InvalidOperationException("Injected unsubscribe failure.");
using var transitionCancellation = new CancellationTokenSource();
CancellationToken requestToken = default;
CancellationToken compensationToken = default;
owner.AsyncManager.EventSubscriptionCallback = async (unsubscribe, ct) =>
{
if (unsubscribe)
{
requestToken = ct;
throw failure;
}
compensationToken = ct;
entered.TrySetResult(true);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
};
Task<ServiceResult>? lifecycleTransition = null;
Task transition;
if (deleteNode)
{
transition = owner.AsyncManager.DeleteNodeAsync(
owner.SystemContext, source.NodeId, transitionCancellation.Token).AsTask();
}
else
{
lifecycleTransition = compensateDetach
? owner.Lifecycle.DetachMonitoredItemAsync(eventItem, transitionCancellation.Token).AsTask()
: owner.Lifecycle.AttachMonitoredItemAsync(eventItem, transitionCancellation.Token).AsTask();
transition = lifecycleTransition;
}
Task<MonitoredItem>? creation = null;
MonitoredItem? created = null;
try
{
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
if (compensateDetach)
{
transitionCancellation.Cancel();
clock.Advance(TimeSpan.FromSeconds(5) - TimeSpan.FromTicks(1));
using (Assert.EnterMultipleScope())
{
Assert.That(requestToken, Is.EqualTo(transitionCancellation.Token));
Assert.That(requestToken.IsCancellationRequested, Is.True);
Assert.That(compensationToken.CanBeCanceled, Is.True);
Assert.That(compensationToken, Is.Not.EqualTo(requestToken));
Assert.That(compensationToken.IsCancellationRequested, Is.False,
"Compensation must survive request cancellation until its own deadline.");
}
}
creation = CreateIndependentDataChangeItemAsync(owner.AsyncManager, value.NodeId);
created = await creation.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
using (Assert.EnterMultipleScope())
{
Assert.That(release.Task.IsCompleted, Is.False);
Assert.That(transition.IsCompleted, Is.False,
"The unrelated service must finish before event readiness is released.");
Assert.That(created.NodeId, Is.EqualTo(value.NodeId));
Assert.That(created.AttributeId, Is.EqualTo(Attributes.Value));
Assert.That(created.Id, Is.Not.EqualTo(eventItem.Id));
Assert.That(owner.Owns(created.NodeManager), Is.True);
Assert.That(((IDetachableMonitoredItem)created).IsDetached, Is.False);
}
release.TrySetResult(true);
if (compensateDetach)
{
InvalidOperationException error = Assert.ThrowsAsync<InvalidOperationException>(
async () => await transition.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false))!;
Assert.That(error, Is.SameAs(failure));
clock.Advance(TimeSpan.FromTicks(1));
Assert.That(compensationToken.IsCancellationRequested, Is.False,
"Completed compensation must retire its deadline timer.");
}
else
{
ServiceResult result = await lifecycleTransition!.WaitAsync(TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
}
IReadOnlyList<IMonitoredItem> snapshot = await owner.Lifecycle.GetMonitoredItemsSnapshotAsync()
.ConfigureAwait(false);
using (Assert.EnterMultipleScope())
{
Assert.That(snapshot, Is.EquivalentTo(new IMonitoredItem[] { eventItem, created }));
Assert.That(lifecycle.IsDetached, Is.False);
Assert.That(owner.Owns(eventItem.NodeManager), Is.True);
Assert.That(source.AreEventsMonitored, Is.True);
}
if (deleteNode)
{
Assert.That(owner.ContainsNode(source.NodeId), Is.True);
Assert.That(lifecycle.IsDeleted, Is.False);
owner.AsyncManager.EventSubscriptionCallback = static (_, _) => default;
bool deleted = await owner.DeleteNodeAsync(source.NodeId).ConfigureAwait(false);
snapshot = await owner.Lifecycle.GetMonitoredItemsSnapshotAsync().ConfigureAwait(false);
using (Assert.EnterMultipleScope())
{
Assert.That(deleted, Is.True);
Assert.That(snapshot, Is.EqualTo(new IMonitoredItem[] { created }));
Assert.That(owner.ContainsNode(source.NodeId), Is.False);
Assert.That(lifecycle.IsDeleted, Is.True);
Assert.That(lifecycle.IsDetached, Is.True);
Assert.That(source.AreEventsMonitored, Is.False);
}
}
}
finally
{
release.TrySetResult(true);
try
{
await transition.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
catch (InvalidOperationException exception) when (ReferenceEquals(exception, failure))
{
}
owner.AsyncManager.EventSubscriptionCallback = static (_, _) => default;
if (creation != null)
{
created ??= await creation.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
if (created != null)
{
await owner.Lifecycle.DetachMonitoredItemAsync(created).ConfigureAwait(false);
created.Dispose();
}
await owner.Lifecycle.DetachMonitoredItemAsync(eventItem).ConfigureAwait(false);
}
}
}
private static async Task<MonitoredItem> CreateIndependentDataChangeItemAsync(
TestableAsyncCustomNodeManager manager,
NodeId nodeId)
{
var request = new MonitoredItemCreateRequest
{
ItemToMonitor = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value },
MonitoringMode = MonitoringMode.Reporting,
RequestedParameters = new MonitoringParameters
{
ClientHandle = 17,
SamplingInterval = 1000,
QueueSize = 10,
DiscardOldest = true
}
};
var identity = new Mock<IUserIdentity>();
var session = new Mock<ISession>();
session.SetupGet(item => item.Identity).Returns(identity.Object);
session.SetupGet(item => item.EffectiveIdentity).Returns(identity.Object);
session.SetupGet(item => item.PreferredLocales).Returns([]);
using var context = new OperationContext(
new RequestHeader(), null!, RequestType.CreateMonitoredItems, RequestLifetime.None, session.Object);
var errors = new ServiceResult[1];
var filterErrors = new MonitoringFilterResult[1];
var items = new IMonitoredItem[1];
await manager.CreateMonitoredItemsAsync(
context, 1, 1000, TimestampsToReturn.Both, [request], errors, filterErrors, items,
false, new MonitoredItemIdFactory()).ConfigureAwait(false);
Assert.That(errors[0].StatusCode, Is.EqualTo(StatusCodes.Good));
Assert.That(items[0], Is.TypeOf<MonitoredItem>());
return (MonitoredItem)items[0];
}
private static ManagerOwner CreateManager(IServerInternal server, ManagerPath path)
{
ApplicationConfiguration configuration = CreateConfiguration();
var logger = new Mock<ILogger>();
bool sampling = path is ManagerPath.AsyncSamplingGroup or
ManagerPath.SyncAdapterSamplingGroup;
if (path is ManagerPath.AsyncMonitoredNode or ManagerPath.AsyncSamplingGroup)
{
var manager = new TestableAsyncCustomNodeManager(
server,
configuration,
sampling,
logger.Object,
DeterministicServerMock.TestNamespaceUri);
return new ManagerOwner(manager);
}
var syncManager = new TestableCustomNodeManager2(
server,
configuration,
sampling,
logger.Object,
DeterministicServerMock.TestNamespaceUri);
return new ManagerOwner(syncManager);
}
private static ApplicationConfiguration CreateConfiguration()
{
return new ApplicationConfiguration
{
ServerConfiguration = new ServerConfiguration
{
MaxNotificationQueueSize = 100,
MaxDurableNotificationQueueSize = 200,
AvailableSamplingRates = []
}
};
}
private static StoredMonitoredItem CreateStoredMonitoredItem(uint id, NodeId nodeId)
{
return new StoredMonitoredItem
{
SubscriptionId = 1,
Id = id,
TypeMask = MonitoredItemTypeMask.DataChange,
NodeId = nodeId,
AttributeId = Attributes.Value,
DiagnosticsMasks = DiagnosticsMasks.None,
TimestampsToReturn = TimestampsToReturn.Both,
ClientHandle = id,
MonitoringMode = MonitoringMode.Reporting,
SamplingInterval = 1000,
QueueSize = 1,
DiscardOldest = true,
SourceSamplingInterval = 1000,
IsDurable = true,
LastValue = new DataValue(new Variant(1), StatusCodes.Good),
LastError = ServiceResult.Good
};
}
[Test]
[Category("NodeManagerLifecycle")]
public async Task AttachSkipsMonitoredItemDisposedBeforeTheHandOverAsync()
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
{
Mock<IAsyncNodeManager> originalNodeManager = new();
MonitoredItem item = CreateMonitoredItem(
server.Object,
originalNodeManager.Object,
new object(),
new NodeId("Disposed", 1));
var current = new TestNodeManagerLifecycle();
var replacement = new TestNodeManagerLifecycle();
var transition = new NodeManagerLifecycle.MonitoredItemTransition(
server.Object,
current,
replacement,
compatibleItems: [item],
deletedItems: [],
isOwnedBySubscription: static _ => true);
// The Client deleted the item while the reload was running.
item.Dispose();
List<Exception> failures = await transition
.AttachCompatibleAsync(CancellationToken.None)
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(failures, Is.Empty);
Assert.That(replacement.AttachCount, Is.Zero);
});
}
}
[Test]
[Category("NodeManagerLifecycle")]
public async Task AttachUndoesHandOverWhenItemIsDisposedWhileAttachingAsync()
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
{
Mock<IAsyncNodeManager> originalNodeManager = new();
MonitoredItem item = CreateMonitoredItem(
server.Object,
originalNodeManager.Object,
new object(),
new NodeId("Racing", 1));
var current = new TestNodeManagerLifecycle();
var replacement = new TestNodeManagerLifecycle
{
// The delete lands after the ownership check and before the attach returns.
AttachCallback = attaching =>
{
((MonitoredItem)attaching).Dispose();
return ServiceResult.Good;
}
};
var transition = new NodeManagerLifecycle.MonitoredItemTransition(
server.Object,
current,
replacement,
compatibleItems: [item],
deletedItems: [],
isOwnedBySubscription: static _ => true);
List<Exception> failures = await transition
.AttachCompatibleAsync(CancellationToken.None)
.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(failures, Is.Empty);
Assert.That(replacement.AttachCount, Is.EqualTo(1));
// The replacement must not be left sampling a disposed item.
Assert.That(replacement.DetachCount, Is.EqualTo(1));
Assert.That(item.ManagerHandle, Is.SameAs(MonitoredItem.DetachedHandle));
});
}
}
private static MonitoredItem CreateMonitoredItem(
IServerInternal server,
IAsyncNodeManager nodeManager,
object managerHandle,
NodeId nodeId,
uint id = 2)
{
var identity = new Mock<IUserIdentity>();
var session = new Mock<ISession>();
session.SetupGet(value => value.Identity).Returns(identity.Object);
session.SetupGet(value => value.EffectiveIdentity).Returns(identity.Object);
session.SetupGet(value => value.PreferredLocales).Returns([]);
var subscription = new Mock<ISubscription>();
subscription.SetupGet(value => value.Session).Returns(session.Object);
subscription.SetupGet(value => value.EffectiveIdentity).Returns(identity.Object);
return new MonitoredItem(
server,
nodeManager,
managerHandle,
subscriptionId: 1,
id,
itemToMonitor: new ReadValueId
{
NodeId = nodeId,
AttributeId = Attributes.Value
},
diagnosticsMasks: DiagnosticsMasks.None,
timestampsToReturn: TimestampsToReturn.Both,
monitoringMode: MonitoringMode.Reporting,
clientHandle: 3,
originalFilter: null,
filterToUse: null,
range: null,
samplingInterval: 1000,
queueSize: 10,
discardOldest: true,
sourceSamplingInterval: 1000)
{
SubscriptionCallback = subscription.Object
};
}
private static MonitoredItem CreateEventMonitoredItem(
IServerInternal server,
IAsyncNodeManager nodeManager,
object managerHandle,
NodeId nodeId)
{
var identity = new Mock<IUserIdentity>();
var session = new Mock<ISession>();
session.SetupGet(value => value.Identity).Returns(identity.Object);
session.SetupGet(value => value.EffectiveIdentity).Returns(identity.Object);
session.SetupGet(value => value.PreferredLocales).Returns([]);
var subscription = new Mock<ISubscription>();
subscription.SetupGet(value => value.Session).Returns(session.Object);
subscription.SetupGet(value => value.EffectiveIdentity).Returns(identity.Object);
var filter = new EventFilter();
return new MonitoredItem(
server,
nodeManager,
managerHandle,
subscriptionId: 1,
id: 2,
itemToMonitor: new ReadValueId
{
NodeId = nodeId,
AttributeId = Attributes.EventNotifier
},
diagnosticsMasks: DiagnosticsMasks.None,
timestampsToReturn: TimestampsToReturn.Both,
monitoringMode: MonitoringMode.Reporting,
clientHandle: 3,
originalFilter: filter,
filterToUse: filter,
range: null,
samplingInterval: 0,
queueSize: 10,
discardOldest: true,
sourceSamplingInterval: 0)
{
SubscriptionCallback = subscription.Object
};
}
private sealed class TestNodeManagerLifecycle : INodeManagerMonitoredItemLifecycle
{
public Func<IMonitoredItem, ServiceResult> AttachCallback { get; set; } =
static _ => ServiceResult.Good;
public Func<IMonitoredItem, ServiceResult> DetachCallback { get; set; } =
static _ => ServiceResult.Good;
public Func<IMonitoredItem, ServiceResult> RestoreCallback { get; set; } =
static _ => ServiceResult.Good;
public int AttachCount { get; private set; }
public int DetachCount { get; private set; }
public int RestoreCount { get; private set; }
public ValueTask<IReadOnlyList<IMonitoredItem>> GetMonitoredItemsSnapshotAsync(
IReadOnlyCollection<NodeId>? nodeIds = null,
CancellationToken cancellationToken = default)
{
return new ValueTask<IReadOnlyList<IMonitoredItem>>([]);
}
public ValueTask<ServiceResult> CanAttachMonitoredItemAsync(
IMonitoredItem monitoredItem,
CancellationToken cancellationToken = default)
{
return new ValueTask<ServiceResult>(ServiceResult.Good);
}
public ValueTask<ServiceResult> DetachMonitoredItemAsync(
IMonitoredItem monitoredItem,
CancellationToken cancellationToken = default)
{
DetachCount++;
return new ValueTask<ServiceResult>(DetachCallback(monitoredItem));
}
public ValueTask<ServiceResult> AttachMonitoredItemAsync(
IMonitoredItem monitoredItem,
CancellationToken cancellationToken = default)
{
AttachCount++;
return new ValueTask<ServiceResult>(AttachCallback(monitoredItem));
}
public ValueTask<ServiceResult> RecoverMonitoredItemAsync(
IMonitoredItem monitoredItem,
CancellationToken cancellationToken = default)
{
RestoreCount++;
return new ValueTask<ServiceResult>(RestoreCallback(monitoredItem));
}
}
private sealed class ManagerOwner : IDisposable
{
private readonly TestableAsyncCustomNodeManager m_asyncManager = null!;
private readonly TestableCustomNodeManager2 m_syncManager = null!;
public ManagerOwner(TestableAsyncCustomNodeManager manager)
{
m_asyncManager = manager;
Lifecycle = manager;
NamespaceIndex = manager.NamespaceIndex;
SystemContext = manager.SystemContext;
}
public ManagerOwner(TestableCustomNodeManager2 manager)
{
m_syncManager = manager;
IAsyncNodeManager adapter = manager.ToAsyncNodeManager();
Lifecycle = (INodeManagerMonitoredItemLifecycle)adapter;
NamespaceIndex = manager.NamespaceIndex;
SystemContext = manager.SystemContext;
}
public INodeManagerMonitoredItemLifecycle Lifecycle { get; }
public TestableAsyncCustomNodeManager AsyncManager => m_asyncManager ??
throw new InvalidOperationException("This scenario requires an async node manager.");
public ushort NamespaceIndex { get; }
public ServerSystemContext SystemContext { get; }
public ValueTask AddNodeAsync(NodeState node)
{
if (m_asyncManager != null)
{
return m_asyncManager.AddPredefinedNodePublicAsync(SystemContext, node);
}
m_syncManager!.AddPredefinedNodePublic(SystemContext, node);
return default;
}
public bool Owns(IAsyncNodeManager nodeManager)
{
if (m_asyncManager != null)
{
return ReferenceEquals(nodeManager, m_asyncManager);
}
return nodeManager is AsyncNodeManagerAdapter adapter &&
ReferenceEquals(adapter.SyncNodeManager, m_syncManager);
}
public void SetEventSubscriptionCallback(Action<bool> callback)
{
if (m_asyncManager != null)
{
m_asyncManager.EventSubscriptionCallback = (unsubscribe, _) =>
{
callback(unsubscribe);
return default;
};
return;
}
m_syncManager!.EventSubscriptionCallback = callback;
}
public void SetNodeRemovedCallback(Action<NodeState> callback)
{
if (m_asyncManager != null)
{
m_asyncManager.NodeRemovedCallback = (node, _) =>
{
callback(node);
return default;
};
return;
}
m_syncManager!.NodeRemovedCallback = callback;
}
public ValueTask<bool> DeleteNodeAsync(NodeId nodeId)
{
if (m_asyncManager != null)
{
return m_asyncManager.DeleteNodeAsync(SystemContext, nodeId);
}
return new ValueTask<bool>(m_syncManager!.DeleteNode(SystemContext, nodeId));
}
public bool ContainsNode(NodeId nodeId)
{
return m_asyncManager?.PredefinedNodes.ContainsKey(nodeId) ??
m_syncManager!.PredefinedNodes.ContainsKey(nodeId);
}
public void Dispose()
{
m_asyncManager?.Dispose();
m_syncManager?.Dispose();
}
}
private sealed class TrackingSamplingGroupManager : SamplingGroupManager
{
public TrackingSamplingGroupManager(
IServerInternal server,
IAsyncNodeManager nodeManager)
: base(server, nodeManager, 100, 200, [])
{
}
public int StartCount { get; private set; }
public int StopCount { get; private set; }
public int ApplyCount { get; private set; }
public bool FailNextApply { get; set; }
public override void StartMonitoring(
OperationContext context,
ISampledDataChangeMonitoredItem monitoredItem,
IUserIdentity? savedOwnerIdentity = null)
{
StartCount++;
}
public override void StopMonitoring(ISampledDataChangeMonitoredItem monitoredItem)
{
StopCount++;
}
public override void ApplyChanges()
{
ApplyCount++;
if (FailNextApply)
{
FailNextApply = false;
throw new InvalidOperationException("Injected apply failure.");
}
}
}
}
}