a8284f499e
N1: a full alarm-state snapshot also sets the condition's Quality, so it now supersedes an earlier quality-only write for the same id in the deferred buffer; a quality arriving after the state is kept and replays after it. N2 / M1: the replay is not inert on a node the apply KEPT - an SDK-applied Acknowledge/Confirm/Shelve or an optimistic client Write in the window would be rolled back (with spurious Part 9 events). The replay is now scoped to nodes the apply created: variables the applier ensured during this apply (AddressSpaceApplier.BeginRecordingEnsuredVariables) or every variable after a full rebuild; conditions in the applied composition but not the previous one (also on a rebuild, which carries a kept condition's state over). Option (b), a sink that reports whether a live write landed, needs the Commons DeferredAddressSpaceSink seam outside this lane (follow-up F7). M2: the benchmark remark now matches its Category=Benchmark trait. M3: TestHooks and the seam-carrying constructor are internal; PropsForTests produces the actor through an IIndirectActorProducer. M4: pinned replay-nothing-and-drop-the-buffer after a failed plan. M5: PreRestart answers every queued rebuild requester (degraded). Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
197 lines
11 KiB
C#
197 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.
|
||
/// Tagged <c>Category=Benchmark</c>, but no filter excludes that category (CI drops only E2E + LiveIntegration),
|
||
/// so it runs by default — on purpose:
|
||
/// 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();
|
||
}
|
||
}
|