perf(client): lazy child detection in BrowseAsync, NodeId-indexed UI notifications
R08 3.6: BrowseAsync no longer issues one HasChildren Browse per Object child; an Object reports HasChildren (may have children) and is confirmed when expanded or recursed. ISessionAdapter.HasChildrenAsync is removed. The UI subscriptions panel routes each notification through a NodeId index kept in step with ActiveSubscriptions (add/remove/replace/reset) instead of scanning every row. Refs #747 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -28,8 +28,6 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
public int BrowseCount { get; private set; }
|
||||
/// <summary>Gets the number of times BrowseNextAsync has been called.</summary>
|
||||
public int BrowseNextCount { get; private set; }
|
||||
/// <summary>Gets the number of times HasChildrenAsync has been called.</summary>
|
||||
public int HasChildrenCount { get; private set; }
|
||||
/// <summary>Gets the number of times HistoryReadRawAsync has been called.</summary>
|
||||
public int HistoryReadRawCount { get; private set; }
|
||||
/// <summary>Gets the number of times HistoryReadAggregateAsync has been called.</summary>
|
||||
@@ -57,8 +55,6 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
public ReferenceDescriptionCollection BrowseNextResponse { get; set; } = [];
|
||||
/// <summary>Gets or sets the continuation point for browse-next operations.</summary>
|
||||
public byte[]? BrowseNextContinuationPoint { get; set; }
|
||||
/// <summary>Gets or sets the result returned by has-children operations.</summary>
|
||||
public bool HasChildrenResponse { get; set; } = false;
|
||||
|
||||
/// <summary>Gets or sets the historical values returned by raw history-read operations.</summary>
|
||||
public List<DataValue> HistoryReadRawResponse { get; set; } = [];
|
||||
@@ -150,13 +146,6 @@ internal sealed class FakeSessionAdapter : ISessionAdapter
|
||||
return Task.FromResult((BrowseNextContinuationPoint, BrowseNextResponse));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct)
|
||||
{
|
||||
HasChildrenCount++;
|
||||
return Task.FromResult(HasChildrenResponse);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DataValue>> HistoryReadRawAsync(
|
||||
NodeId nodeId, DateTime startTime, DateTime endTime, int maxValues, CancellationToken ct)
|
||||
|
||||
@@ -384,10 +384,12 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that object nodes trigger child-detection checks so the client can mark expandable branches.
|
||||
/// R08 3.6: browsing a level issues ONE Browse (plus BrowseNext pages) however many Object children it holds. Child
|
||||
/// detection is lazy: an Object may have children and is confirmed when it is expanded/recursed, instead of one
|
||||
/// extra Browse per Object child (~50 000 calls for a recursive subscribe over a 50k-node raw tree).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BrowseAsync_ObjectNode_ChecksHasChildren()
|
||||
public async Task BrowseAsync_ObjectChildren_MayHaveChildren_WithoutAPerChildBrowse()
|
||||
{
|
||||
var session = new FakeSessionAdapter
|
||||
{
|
||||
@@ -398,17 +400,29 @@ public class OpcUaClientServiceTests : IDisposable
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Folder1"),
|
||||
DisplayName = new LocalizedText("Folder1"),
|
||||
NodeClass = NodeClass.Object
|
||||
},
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Folder2"),
|
||||
DisplayName = new LocalizedText("Folder2"),
|
||||
NodeClass = NodeClass.Object
|
||||
},
|
||||
new ReferenceDescription
|
||||
{
|
||||
NodeId = new ExpandedNodeId("ns=2;s=Tag"),
|
||||
DisplayName = new LocalizedText("Tag"),
|
||||
NodeClass = NodeClass.Variable
|
||||
}
|
||||
],
|
||||
HasChildrenResponse = true
|
||||
]
|
||||
};
|
||||
_sessionFactory.EnqueueSession(session);
|
||||
await _service.ConnectAsync(ValidSettings());
|
||||
|
||||
var results = await _service.BrowseAsync();
|
||||
|
||||
results[0].HasChildren.ShouldBeTrue();
|
||||
session.HasChildrenCount.ShouldBe(1);
|
||||
results.Select(r => r.HasChildren).ShouldBe([true, true, false]);
|
||||
session.BrowseCount.ShouldBe(1);
|
||||
session.BrowseNextCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -111,6 +111,70 @@ public class SubscriptionsViewModelTests
|
||||
_vm.ActiveSubscriptions[0].Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R08 3.6: notifications are routed through a NodeId index, not a scan of every row. The index follows every
|
||||
/// mutation of <see cref="SubscriptionsViewModel.ActiveSubscriptions"/> (add, remove, replace, clear).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NodeId_index_follows_every_collection_mutation()
|
||||
{
|
||||
var a = new SubscriptionItemViewModel("ns=2;s=A", 1000);
|
||||
var b = new SubscriptionItemViewModel("ns=2;s=B", 1000);
|
||||
var c = new SubscriptionItemViewModel("ns=2;s=C", 1000);
|
||||
_vm.ActiveSubscriptions.Add(a);
|
||||
_vm.ActiveSubscriptions.Add(b);
|
||||
_vm.ActiveSubscriptions.Add(c);
|
||||
_vm.IndexedNodeCount.ShouldBe(3);
|
||||
|
||||
_vm.ActiveSubscriptions.Remove(b);
|
||||
_vm.IndexedNodeCount.ShouldBe(2);
|
||||
|
||||
_vm.ActiveSubscriptions[0] = new SubscriptionItemViewModel("ns=2;s=D", 1000); // replaces A
|
||||
Raise("ns=2;s=A", 1);
|
||||
Raise("ns=2;s=D", 2);
|
||||
a.Value.ShouldBeNull();
|
||||
_vm.ActiveSubscriptions[0].Value.ShouldBe("2");
|
||||
|
||||
_vm.ActiveSubscriptions.Clear();
|
||||
_vm.IndexedNodeCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>A removed row stops receiving values while its sibling (the positive control) still does.</summary>
|
||||
[Fact]
|
||||
public void DataChanged_after_remove_updates_only_the_remaining_rows()
|
||||
{
|
||||
var a = new SubscriptionItemViewModel("ns=2;s=A", 1000);
|
||||
var b = new SubscriptionItemViewModel("ns=2;s=B", 1000);
|
||||
_vm.ActiveSubscriptions.Add(a);
|
||||
_vm.ActiveSubscriptions.Add(b);
|
||||
_vm.ActiveSubscriptions.Remove(a);
|
||||
|
||||
Raise("ns=2;s=A", 7);
|
||||
Raise("ns=2;s=B", 42);
|
||||
|
||||
a.Value.ShouldBeNull();
|
||||
b.Value.ShouldBe("42");
|
||||
}
|
||||
|
||||
/// <summary>Two rows for the same node (no dedup on the manual-add path) both update, as before.</summary>
|
||||
[Fact]
|
||||
public void DataChanged_updates_every_row_for_the_node()
|
||||
{
|
||||
var first = new SubscriptionItemViewModel("ns=2;s=A", 1000);
|
||||
var second = new SubscriptionItemViewModel("ns=2;s=A", 500);
|
||||
_vm.ActiveSubscriptions.Add(first);
|
||||
_vm.ActiveSubscriptions.Add(second);
|
||||
|
||||
Raise("ns=2;s=A", 5);
|
||||
|
||||
first.Value.ShouldBe("5");
|
||||
second.Value.ShouldBe("5");
|
||||
}
|
||||
|
||||
private void Raise(string nodeId, int value) =>
|
||||
_service.RaiseDataChanged(new DataChangedEventArgs(nodeId,
|
||||
new DataValue(new Variant(value), StatusCodes.Good, DateTime.UtcNow)));
|
||||
|
||||
/// <summary>Verifies that Clear removes all subscriptions.</summary>
|
||||
[Fact]
|
||||
public void Clear_RemovesAllSubscriptions()
|
||||
|
||||
Reference in New Issue
Block a user