f966efcfad
C1 (critical): B6 computes the plan off the pinned thread while B1 made writes non-creating, so a value/status/alarm write for a node the in-flight plan adds was skipped, and change-driven drivers never republish it (BadWaitingForInitialData indefinitely; on a full rebuild every re-subscribe value landed on discarded nodes). OpcUaPublishActor now applies writes immediately AND remembers the latest per (realm, id) while a plan is in flight or queued (value+status compose; alarm state/quality coalesce to the latest, which the node's delta gate makes inert on an existing condition), replays it after each apply before answering, and drops it once the pipeline is idle. Bounded (250k keys by default; beyond it new keys are not held, logged once). I2: the data-plane-authority parse runs in its own try inside the plan task; a throw is logged and never degrades the address-space apply. I3: the SDK's DeleteMonitoredItems releases an item's component-cache reference on a Good delete; a tombstoned item's reference was already released, so the delete took one from the re-created node's entry. Released items are remembered and the orphan delete takes one back first. Event items never held a reference (EventManager + SubscribeToEvents), so the three event-item releases are gone. I4: direct tests for the HistoryUpdate override's per-request updater check and the HistoryReadEvents override's source guard (both reached by restoring the bit after a reset - the B7 race window); fixed the false remark. Minors: the MaterialiseAlarmCondition kind-swap tombstones the dropped instance; a bytes-less rebuild arriving mid-plan queues behind it (replies in order); a PlanReady/PlanFailed from a previous actor incarnation is ignored and its requester answered degraded; the tie-cluster end bound of an uncapped read fits against the backend ceiling (defence in depth - guarded by a sweep test); the lock-time benchmark stays in the default run, headroom noted. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
231 lines
12 KiB
C#
231 lines
12 KiB
C#
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Server;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Review 01 L2, the half the wire tests (<c>StaleMonitoredNodeWireTests</c>) cannot reach: an item the
|
|
/// ACL revalidation pass has STOOD DOWN is on no SDK fan-out list, so the tombstone a removal runs must find
|
|
/// it through the revalidation map. Otherwise it keeps a component-cache reference to the dead node — and
|
|
/// the cache hands that node to the next item created on the NodeId.
|
|
/// </summary>
|
|
public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable
|
|
{
|
|
private const string Cluster = "SITE-A";
|
|
private const string Principal = "svc";
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-{Guid.NewGuid():N}");
|
|
|
|
/// <summary>A surgical remove tells a stood-down item and drops it from the revalidation map.</summary>
|
|
[Fact]
|
|
public async Task A_surgical_remove_tells_and_forgets_a_stood_down_item()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var (item, nodeId) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
|
|
|
|
nm.RemoveVariableNode("Plant/Modbus/dev1/Speed", AddressSpaceRealm.Raw).ShouldBeTrue();
|
|
|
|
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown);
|
|
nm.LiveItemCount.ShouldBe(0, "the item's node is gone; the map must not keep re-authorizing it");
|
|
nm.IsNodeMonitored(nodeId).ShouldBeFalse();
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>A rebuild sweeps every stood-down item.</summary>
|
|
[Fact]
|
|
public async Task A_rebuild_tells_and_forgets_every_stood_down_item()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
|
|
|
|
nm.RebuildAddressSpace();
|
|
nm.CompleteAddressSpaceApply();
|
|
|
|
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown);
|
|
nm.LiveItemCount.ShouldBe(0);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Positive control for the sweep: a stood-down item on a SURVIVING node is left alone by a surgical
|
|
/// remove of a sibling, and the next permitted pass restores it.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_stood_down_item_on_a_surviving_node_is_untouched_and_still_restorable()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
nm.EnsureVariable("Plant/Modbus/dev1/Other", null, "Other", "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
|
var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed");
|
|
|
|
nm.RemoveVariableNode("Plant/Modbus/dev1/Other", AddressSpaceRealm.Raw).ShouldBeTrue();
|
|
|
|
item.Statuses.ShouldNotContain((StatusCode)StatusCodes.BadNodeIdUnknown);
|
|
nm.LiveItemCount.ShouldBe(1);
|
|
|
|
// Grant restored ⇒ the pass re-attaches it (a second stand-down proves the restore happened).
|
|
nm.Authorizer = Authorizer(permits: true);
|
|
nm.RevalidateMonitoredItems();
|
|
nm.Authorizer = Authorizer(permits: false);
|
|
nm.RevalidateMonitoredItems();
|
|
item.Statuses.Count(s => s == StatusCodes.BadUserAccessDenied).ShouldBe(2);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review fix round 1 (minor): the same-id kind swap (scripted ⇄ native) in <c>MaterialiseAlarmCondition</c>
|
|
/// drops the old condition instance — one more removal path, so it tombstones like the others. Believed
|
|
/// unreachable from a composition (native ids are RawPaths in Raw, scripted ids live in Uns), so it is driven
|
|
/// directly.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_condition_kind_swap_tells_and_forgets_items_on_the_dropped_instance()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
nm.EnsureFolder("eq-1", null, "Equipment", AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: false);
|
|
var nodeId = new NodeId("al-1", nm.TryGetFolder("eq-1")!.NodeId.NamespaceIndex);
|
|
var handle = (NodeHandle)nm.GetManagerHandle(nodeId);
|
|
handle.ShouldNotBeNull("non-vacuity: the condition is materialised");
|
|
var item = new StubItem(handle, Identity());
|
|
nm.TrackLiveMonitoredItem(handle, item);
|
|
nm.Authorizer = Authorizer(permits: false);
|
|
nm.RevalidateMonitoredItems();
|
|
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadUserAccessDenied, "non-vacuity: stood down");
|
|
|
|
nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: true);
|
|
|
|
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown, "the dropped instance's item is told");
|
|
nm.LiveItemCount.ShouldBe(0);
|
|
((NodeHandle)nm.GetManagerHandle(nodeId)).ShouldNotBeNull("positive control: the swapped-in condition exists");
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
/// <summary>Registers a stub item on <paramref name="rawPath"/> and stands it down through a denying pass.</summary>
|
|
private static async Task<(StubItem Item, NodeId NodeId)> StandDownAsync(OtOpcUaNodeManager nm, string rawPath)
|
|
{
|
|
nm.EnsureVariable(rawPath, null, rawPath, "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
|
var nodeId = nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId;
|
|
var handle = (NodeHandle)nm.GetManagerHandle(nodeId);
|
|
var item = new StubItem(handle, Identity());
|
|
nm.TrackLiveMonitoredItem(handle, item);
|
|
|
|
nm.Authorizer = Authorizer(permits: false);
|
|
nm.RevalidateMonitoredItems();
|
|
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadUserAccessDenied, "non-vacuity: the item really was stood down");
|
|
await Task.CompletedTask;
|
|
return (item, nodeId);
|
|
}
|
|
|
|
private static DataPlaneAuthorizer Authorizer(bool permits)
|
|
{
|
|
var state = new DataPlaneAuthorityState();
|
|
var grants = permits
|
|
? new[]
|
|
{
|
|
new NodeAcl
|
|
{
|
|
NodeAclId = "acl-cluster", ClusterId = Cluster, Principal = Principal,
|
|
ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.Subscribe,
|
|
},
|
|
}
|
|
: [];
|
|
state.Apply(Cluster, "rev-1", [], grants, new Dictionary<AclScopeRef, IReadOnlyList<NodeAclPath>>());
|
|
return new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance);
|
|
}
|
|
|
|
private static RoleCarryingUserIdentity Identity() =>
|
|
new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: null);
|
|
|
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
|
|
await OpcUaHostStart.StartAsync(
|
|
port => new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.StaleTest",
|
|
ApplicationUri = $"urn:OtOpcUa.StaleTest:{Guid.NewGuid():N}",
|
|
OpcUaPort = port,
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance),
|
|
() => new OtOpcUaSdkServer(), Ct);
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
|
|
}
|
|
|
|
/// <summary>The minimum of a sampled item the removal path touches: its handle, identity and value queue.</summary>
|
|
private sealed class StubItem(NodeHandle handle, IUserIdentity identity) : ISampledDataChangeMonitoredItem
|
|
{
|
|
public List<StatusCode> Statuses { get; } = [];
|
|
public IUserIdentity EffectiveIdentity => identity;
|
|
public object ManagerHandle => handle;
|
|
public NodeId NodeId => handle.NodeId;
|
|
public void QueueValue(DataValue value, ServiceResult error, bool ignoreFilters) => Statuses.Add(error?.StatusCode ?? value.StatusCode);
|
|
public void QueueValue(DataValue value, ServiceResult error) => QueueValue(value, error, false);
|
|
public void Dispose() { }
|
|
|
|
public INodeManager NodeManager => throw new NotSupportedException();
|
|
public ISession Session => throw new NotSupportedException();
|
|
public uint Id => throw new NotSupportedException();
|
|
public uint SubscriptionId => throw new NotSupportedException();
|
|
public bool IsDurable => throw new NotSupportedException();
|
|
public uint ClientHandle => throw new NotSupportedException();
|
|
public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
|
public int MonitoredItemType => throw new NotSupportedException();
|
|
public bool IsReadyToPublish => throw new NotSupportedException();
|
|
public bool IsReadyToTrigger { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
|
public bool IsResendData => throw new NotSupportedException();
|
|
public MonitoringMode MonitoringMode => throw new NotSupportedException();
|
|
public double SamplingInterval => throw new NotSupportedException();
|
|
public DataChangeFilter DataChangeFilter => throw new NotSupportedException();
|
|
public uint AttributeId => throw new NotSupportedException();
|
|
public NumericRange IndexRange => throw new NotSupportedException();
|
|
public QualifiedName DataEncoding => throw new NotSupportedException();
|
|
public bool AlwaysReportUpdates { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
|
public DiagnosticsMasks DiagnosticsMasks => throw new NotSupportedException();
|
|
public uint QueueSize => throw new NotSupportedException();
|
|
public double MinimumSamplingInterval => throw new NotSupportedException();
|
|
public int TimeToNextSample => throw new NotSupportedException();
|
|
public void SetupResendDataTrigger() => throw new NotSupportedException();
|
|
public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException();
|
|
public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException();
|
|
public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException();
|
|
public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException();
|
|
public bool Publish(OperationContext context, Queue<MonitoredItemNotification> notifications, Queue<DiagnosticInfo> diagnostics, uint maxNotificationsPerPublish, ILogger logger) => throw new NotSupportedException();
|
|
public void SetSemanticsChanged() => throw new NotSupportedException();
|
|
public void SetStructureChanged() => throw new NotSupportedException();
|
|
public bool SamplingIntervalExpired() => throw new NotSupportedException();
|
|
public ReadValueId GetReadValueId() => throw new NotSupportedException();
|
|
public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn, uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range, double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException();
|
|
public void SetSamplingInterval(double samplingInterval) => throw new NotSupportedException();
|
|
}
|
|
}
|