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
196 lines
11 KiB
C#
196 lines
11 KiB
C#
using System.Diagnostics;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using Opc.Ua;
|
||
using Opc.Ua.Server;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||
|
||
/// <summary>
|
||
/// Review 01 P1 / P2 / P3 — the lock-time budget of the three teardown paths that run under the
|
||
/// manager-wide SDK <c>Lock</c> on the pinned publish thread, where every Read / Browse / Publish waits.
|
||
/// <list type="bullet">
|
||
/// <item>P1: a full rebuild of a 10k-tag device folder was O(k²) per folder because
|
||
/// <c>NodeState.RemoveChild</c> is a linear scan + <c>RemoveAt</c> per leaf (~50M compares).</item>
|
||
/// <item>P2: removing M equipment from an N-node space scanned every map per equipment (O(M·N)).</item>
|
||
/// <item>P3: every surgical condition removal re-walked every condition for the Server-object
|
||
/// monitoring bookkeeping (O(C) per removal) while any <c>i=2253</c> item existed.</item>
|
||
/// </list>
|
||
/// The wall-clock budgets are generous for CI hardware; the before/after numbers are in the commit
|
||
/// message. P3 is pinned by the bookkeeping's own visit counter, which does not depend on the machine.
|
||
/// Kept in the default run on purpose (not a Benchmark category the CI filter would have to learn to exclude):
|
||
/// measured after the fix at 5–6 ms (P1) and 58–64 ms (P2) on a loaded dev machine against the 1 s budget —
|
||
/// ≥ 15× headroom, so it does not flake on slow CI. The flip side: the pre-fix code (75–150 ms / 470–680 ms) also
|
||
/// fits 1 s at these sizes, so the wall-clock legs guard against gross (order-of-magnitude) regressions only; the
|
||
/// machine-independent P3 visit counter is the strict pin.
|
||
/// </summary>
|
||
[Trait("Category", "Benchmark")]
|
||
public sealed class NodeManagerLockTimeBenchmarkTests(ITestOutputHelper output) : IDisposable
|
||
{
|
||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(1);
|
||
|
||
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-bench-{Guid.NewGuid():N}");
|
||
|
||
/// <summary>P1 — a rebuild that discards one device folder holding 10k tags completes within the budget.</summary>
|
||
[Fact]
|
||
public async Task Rebuild_of_a_10k_tag_folder_stays_within_budget()
|
||
{
|
||
var (host, server) = await BootAsync();
|
||
try
|
||
{
|
||
var nm = server.NodeManager!;
|
||
const string device = "Plant/Modbus/dev1";
|
||
nm.EnsureFolder(device, null, "dev1", AddressSpaceRealm.Raw);
|
||
for (var i = 0; i < 10_000; i++)
|
||
nm.EnsureVariable($"{device}/T{i}", device, $"T{i}", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||
nm.VariableCount.ShouldBe(10_000);
|
||
|
||
var sw = Stopwatch.StartNew();
|
||
nm.RebuildAddressSpace();
|
||
sw.Stop();
|
||
nm.CompleteAddressSpaceApply();
|
||
|
||
nm.VariableCount.ShouldBe(0);
|
||
nm.FolderCount.ShouldBe(0);
|
||
// Positive control: the space is really empty for the SDK too, and can be re-populated.
|
||
nm.GetManagerHandle(new NodeId($"{device}/T0", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw))).ShouldBeNull();
|
||
nm.EnsureFolder(device, null, "dev1", AddressSpaceRealm.Raw);
|
||
nm.EnsureVariable($"{device}/T0", device, "T0", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
||
nm.TryGetVariable($"{device}/T0", AddressSpaceRealm.Raw)!.Parent.ShouldBeSameAs(nm.TryGetFolder(device, AddressSpaceRealm.Raw));
|
||
|
||
output.WriteLine($"BENCH rebuild 10k-tag folder: {sw.ElapsedMilliseconds} ms");
|
||
sw.Elapsed.ShouldBeLessThan(Budget, $"rebuild of a 10k-tag folder took {sw.ElapsedMilliseconds} ms");
|
||
}
|
||
finally { await host.DisposeAsync(); }
|
||
}
|
||
|
||
/// <summary>P2 — removing 200 equipment (250 signals each, 50k nodes) completes within the budget.</summary>
|
||
[Fact]
|
||
public async Task Removing_200_equipment_from_a_50k_node_space_stays_within_budget()
|
||
{
|
||
var (host, server) = await BootAsync();
|
||
try
|
||
{
|
||
var nm = server.NodeManager!;
|
||
const int equipmentCount = 200, perEquipment = 250;
|
||
for (var e = 0; e < equipmentCount; e++)
|
||
{
|
||
var equip = $"area/line/eq{e}";
|
||
nm.EnsureFolder(equip, null, $"eq{e}", AddressSpaceRealm.Uns);
|
||
for (var i = 0; i < perEquipment; i++)
|
||
nm.EnsureVariable($"{equip}/S{i}", equip, $"S{i}", "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||
}
|
||
// A survivor the removals must not touch.
|
||
nm.EnsureFolder("area/line/keep", null, "keep", AddressSpaceRealm.Uns);
|
||
nm.EnsureVariable("area/line/keep/S0", "area/line/keep", "S0", "Float", writable: false, realm: AddressSpaceRealm.Uns);
|
||
nm.VariableCount.ShouldBe(equipmentCount * perEquipment + 1);
|
||
|
||
var sw = Stopwatch.StartNew();
|
||
for (var e = 0; e < equipmentCount; e++)
|
||
nm.RemoveEquipmentSubtree($"area/line/eq{e}", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||
sw.Stop();
|
||
|
||
nm.VariableCount.ShouldBe(1);
|
||
nm.FolderCount.ShouldBe(1);
|
||
nm.TryGetVariable("area/line/keep/S0").ShouldNotBeNull();
|
||
nm.GetManagerHandle(new NodeId("area/line/eq0/S0", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns))).ShouldBeNull();
|
||
|
||
output.WriteLine($"BENCH remove 200 equipment / 50k nodes: {sw.ElapsedMilliseconds} ms");
|
||
sw.Elapsed.ShouldBeLessThan(Budget, $"removing 200 equipment took {sw.ElapsedMilliseconds} ms");
|
||
}
|
||
finally { await host.DisposeAsync(); }
|
||
}
|
||
|
||
/// <summary>
|
||
/// P3 — with a Server-object item live, a surgical condition removal lowers only that condition's
|
||
/// monitoring unit instead of re-walking every condition: the bookkeeping visits at most one
|
||
/// condition per removal. (A full reconcile visits every condition twice — desired + held.)
|
||
/// </summary>
|
||
[Fact]
|
||
public async Task A_surgical_condition_removal_visits_only_its_own_condition()
|
||
{
|
||
var (host, server) = await BootAsync();
|
||
try
|
||
{
|
||
var nm = server.NodeManager!;
|
||
nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns);
|
||
const int conditions = 50;
|
||
for (var i = 0; i < conditions; i++)
|
||
nm.MaterialiseAlarmCondition($"alm-{i}", "eq-1", $"Alarm {i}", "OffNormalAlarm", 500, AddressSpaceRealm.Uns);
|
||
|
||
// A Server-object (all-events) subscriber, so the per-condition monitoring unit is in play.
|
||
nm.SubscribeToAllEvents(
|
||
new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, identity: null),
|
||
1, new StubEventItem(), unsubscribe: false);
|
||
nm.ConditionEventsMonitored("alm-0", AddressSpaceRealm.Uns).ShouldBeTrue("non-vacuity: the units really were raised");
|
||
|
||
var before = nm.AllEventsMonitoringConditionVisits;
|
||
for (var i = 0; i < 10; i++)
|
||
nm.RemoveAlarmConditionNode($"alm-{i}", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||
var visits = nm.AllEventsMonitoringConditionVisits - before;
|
||
output.WriteLine($"BENCH all-events visits for 10 condition removals over {conditions} conditions: {visits}");
|
||
|
||
visits.ShouldBeLessThanOrEqualTo(10, $"ten removals visited {visits} conditions; a full reconcile per removal would visit ~{10 * 2 * conditions}");
|
||
// The survivors keep their unit.
|
||
nm.ConditionEventsMonitored("alm-49", AddressSpaceRealm.Uns).ShouldBeTrue();
|
||
}
|
||
finally { await host.DisposeAsync(); }
|
||
}
|
||
|
||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
|
||
await OpcUaHostStart.StartAsync(
|
||
port => new OpcUaApplicationHost(
|
||
new OpcUaApplicationHostOptions
|
||
{
|
||
ApplicationName = "OtOpcUa.BenchTest",
|
||
ApplicationUri = $"urn:OtOpcUa.BenchTest:{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 Server-object event item the all-events bookkeeping touches: identity only.</summary>
|
||
private sealed class StubEventItem : IEventMonitoredItem
|
||
{
|
||
public bool MonitoringAllEvents => true;
|
||
public NodeId NodeId => ObjectIds.Server;
|
||
public IUserIdentity EffectiveIdentity => null!;
|
||
public object ManagerHandle => null!;
|
||
public void QueueEvent(IFilterTarget instance) { }
|
||
public void QueueEvent(IFilterTarget instance, bool bypassFilter) { }
|
||
public void Dispose() { }
|
||
|
||
public INodeManager NodeManager => throw new NotSupportedException();
|
||
public ISession Session => throw new NotSupportedException();
|
||
public uint Id => 1;
|
||
public uint SubscriptionId => 1;
|
||
public bool IsDurable => false;
|
||
public uint ClientHandle => 1;
|
||
public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||
public int MonitoredItemType => MonitoredItemTypeMask.Events;
|
||
public bool IsReadyToPublish => false;
|
||
public bool IsReadyToTrigger { get => false; set { } }
|
||
public bool IsResendData => false;
|
||
public MonitoringMode MonitoringMode => MonitoringMode.Reporting;
|
||
public double SamplingInterval => 0;
|
||
public EventFilter EventFilter => null!;
|
||
public void SetupResendDataTrigger() { }
|
||
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<EventFieldList> notifications, uint maxNotificationsPerPublish) => 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();
|
||
}
|
||
}
|