210 lines
12 KiB
C#
210 lines
12 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.
|
||
/// lmxopcua#769: the wall-clock legs only ASSERT their budget when <c>OTOPCUA_BENCH=1</c> (opt-in, like
|
||
/// <c>OTOPCUA_SCRIPT_PERF</c>); the default run still executes every teardown and asserts the state correctness.
|
||
/// Historical note on the budget: no filter excludes <c>Category=Benchmark</c> (CI drops only E2E + LiveIntegration):
|
||
/// 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;
|
||
// lmxopcua#769: 1 s flaked once in a full parallel run (the 50k-node leg). Measured cost is 5-60 ms, so 5 s keeps
|
||
// >= 80x headroom against a CPU-starved CI box while still catching an order-of-magnitude (O(N^2)) regression.
|
||
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(5);
|
||
|
||
// lmxopcua#769: even 5 s flaked in a full parallel run on a starved box, so the wall-clock legs are opt-in, like
|
||
// ScriptRunnerPerfTests (OTOPCUA_SCRIPT_PERF=1): OTOPCUA_BENCH=1 enforces the budget. The default run still executes
|
||
// every teardown and asserts the state correctness (counts, SDK handles, re-population) and logs the measured time.
|
||
private static bool EnforceBudget => Environment.GetEnvironmentVariable("OTOPCUA_BENCH") == "1";
|
||
|
||
private void AssertWithinBudget(Stopwatch sw, string what)
|
||
{
|
||
output.WriteLine($"BENCH {what}: {sw.ElapsedMilliseconds} ms (budget {Budget.TotalMilliseconds:F0} ms, enforced={EnforceBudget})");
|
||
if (EnforceBudget)
|
||
sw.Elapsed.ShouldBeLessThan(Budget, $"{what} took {sw.ElapsedMilliseconds} ms");
|
||
}
|
||
|
||
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));
|
||
|
||
AssertWithinBudget(sw, "rebuild 10k-tag folder");
|
||
}
|
||
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();
|
||
|
||
AssertWithinBudget(sw, "remove 200 equipment / 50k nodes");
|
||
}
|
||
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, requestLifetime: RequestLifetime.None, 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 IAsyncNodeManager 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();
|
||
}
|
||
}
|