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:
@@ -115,22 +115,6 @@ internal sealed class DefaultSessionAdapter : ISessionAdapter
|
||||
return (nextCp, nextRefs ?? []);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct)
|
||||
{
|
||||
var (_, _, references) = await _session.BrowseAsync(
|
||||
null,
|
||||
null,
|
||||
nodeId,
|
||||
1u,
|
||||
BrowseDirection.Forward,
|
||||
ReferenceTypeIds.HierarchicalReferences,
|
||||
true,
|
||||
0u);
|
||||
|
||||
return references != null && references.Count > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Models.HistoryUpdateOutcome> HistoryUpdateAsync(HistoryUpdateDetails details, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -90,14 +90,6 @@ internal interface ISessionAdapter : IDisposable
|
||||
Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)> BrowseNextAsync(
|
||||
byte[] continuationPoint, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a node has any forward hierarchical child references.
|
||||
/// </summary>
|
||||
/// <param name="nodeId">The node to inspect for child objects or variables.</param>
|
||||
/// <param name="ct">The cancellation token that aborts the child lookup.</param>
|
||||
/// <returns><c>true</c> when the node has at least one forward hierarchical child reference; otherwise <c>false</c>.</returns>
|
||||
Task<bool> HasChildrenAsync(NodeId nodeId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reads raw historical data.
|
||||
/// </summary>
|
||||
|
||||
@@ -34,7 +34,9 @@ public sealed class BrowseResult
|
||||
public string NodeClass { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the node has child references.
|
||||
/// Whether the node MAY have child references: <see langword="true"/> for every Object, confirmed lazily when the
|
||||
/// caller browses it (an expanded empty folder simply yields no children). Variables and Methods report
|
||||
/// <see langword="false"/>.
|
||||
/// </summary>
|
||||
public bool HasChildren { get; }
|
||||
}
|
||||
@@ -224,18 +224,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
||||
|
||||
while (references.Count > 0)
|
||||
{
|
||||
// Child detection is lazy: an Object MAY have children and is confirmed when the caller expands or
|
||||
// recurses into it. Probing each Object child with its own Browse made one level of N folders cost N + 1
|
||||
// round trips, and a recursive subscribe over a 50k-node tree ~50k extra Browse calls.
|
||||
foreach (var reference in references)
|
||||
{
|
||||
var childNodeId = ExpandedNodeId.ToNodeId(reference.NodeId, _session.NamespaceUris);
|
||||
var hasChildren = reference.NodeClass == NodeClass.Object &&
|
||||
await _session.HasChildrenAsync(childNodeId, ct);
|
||||
|
||||
results.Add(new BrowseResult(
|
||||
reference.NodeId.ToString(),
|
||||
reference.DisplayName?.Text ?? string.Empty,
|
||||
reference.NodeClass.ToString(),
|
||||
hasChildren));
|
||||
}
|
||||
reference.NodeClass == NodeClass.Object));
|
||||
|
||||
if (continuationPoint is not { Length: > 0 } pending) break;
|
||||
(continuationPoint, references) = await _session.BrowseNextAsync(pending, ct);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Opc.Ua;
|
||||
@@ -19,6 +20,11 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
private readonly IUiDispatcher _dispatcher;
|
||||
private readonly IOpcUaClientService _service;
|
||||
|
||||
// NodeId -> its rows, kept in step with ActiveSubscriptions by OnActiveSubscriptionsChanged, so a notification is
|
||||
// one lookup instead of a scan of every row on the UI thread (2 000 items at 1 Hz was 4M string compares/s).
|
||||
// Touched only on the UI thread, like the collection itself.
|
||||
private readonly Dictionary<string, List<SubscriptionItemViewModel>> _rowsByNodeId = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Last user-visible status message — set when a subscribe/unsubscribe operation fails so the
|
||||
/// shell can surface the diagnostic instead of silently dropping the error. Cleared on success.
|
||||
@@ -49,9 +55,13 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
{
|
||||
_service = service;
|
||||
_dispatcher = dispatcher;
|
||||
ActiveSubscriptions.CollectionChanged += OnActiveSubscriptionsChanged;
|
||||
_service.DataChanged += OnDataChanged;
|
||||
}
|
||||
|
||||
/// <summary>The number of distinct NodeIds in the notification index (test seam).</summary>
|
||||
internal int IndexedNodeCount => _rowsByNodeId.Count;
|
||||
|
||||
/// <summary>Currently active subscriptions.</summary>
|
||||
public ObservableCollection<SubscriptionItemViewModel> ActiveSubscriptions { get; } = [];
|
||||
|
||||
@@ -62,16 +72,42 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
{
|
||||
_dispatcher.Post(() =>
|
||||
{
|
||||
foreach (var item in ActiveSubscriptions)
|
||||
if (item.NodeId == e.NodeId)
|
||||
{
|
||||
item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
|
||||
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
|
||||
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
|
||||
}
|
||||
if (!_rowsByNodeId.TryGetValue(e.NodeId, out var rows)) return;
|
||||
foreach (var item in rows)
|
||||
{
|
||||
item.Value = Helpers.ValueFormatter.Format(e.Value.Value);
|
||||
item.Status = Helpers.StatusCodeFormatter.Format(e.Value.StatusCode);
|
||||
item.Timestamp = e.Value.SourceTimestamp.ToString("O");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void OnActiveSubscriptionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action == NotifyCollectionChangedAction.Reset)
|
||||
{
|
||||
_rowsByNodeId.Clear();
|
||||
foreach (var item in ActiveSubscriptions) Index(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.OldItems != null)
|
||||
foreach (SubscriptionItemViewModel item in e.OldItems)
|
||||
if (_rowsByNodeId.TryGetValue(item.NodeId, out var rows) && rows.Remove(item) && rows.Count == 0)
|
||||
_rowsByNodeId.Remove(item.NodeId);
|
||||
|
||||
if (e.NewItems != null)
|
||||
foreach (SubscriptionItemViewModel item in e.NewItems)
|
||||
Index(item);
|
||||
}
|
||||
|
||||
private void Index(SubscriptionItemViewModel item)
|
||||
{
|
||||
if (!_rowsByNodeId.TryGetValue(item.NodeId, out var rows))
|
||||
_rowsByNodeId[item.NodeId] = rows = [];
|
||||
rows.Add(item);
|
||||
}
|
||||
|
||||
private bool CanAddSubscription()
|
||||
{
|
||||
return IsConnected && !string.IsNullOrWhiteSpace(NewNodeIdText);
|
||||
@@ -149,7 +185,7 @@ public partial class SubscriptionsViewModel : ObservableObject
|
||||
if (!IsConnected || string.IsNullOrWhiteSpace(nodeIdStr)) return;
|
||||
|
||||
// Skip if already subscribed
|
||||
if (ActiveSubscriptions.Any(s => s.NodeId == nodeIdStr)) return;
|
||||
if (_rowsByNodeId.ContainsKey(nodeIdStr)) return;
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user