feat(dcl): bounded recursive OPC UA address-space search adapter (T15)

This commit is contained in:
Joseph Doherty
2026-06-18 02:45:01 -04:00
parent 9ec2450ad5
commit c00c8241b3
5 changed files with 308 additions and 1 deletions
@@ -0,0 +1,43 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
/// <summary>
/// M7-B4 (T15): optional capability for an <see cref="IDataConnection"/>
/// implementation that supports a bounded recursive search of the server's
/// address space. A complement to <see cref="IBrowsableDataConnection"/>:
/// where browse walks one level at a time on user demand, search walks the
/// tree itself (bounded by depth + result caps) and returns the nodes whose
/// DisplayName or root-relative path contains a query substring.
///
/// Consumed only by management/UI flows (e.g. the OPC UA tag picker's
/// "find a tag" box on the instance config page) — never by Instance Actors
/// on the hot path.
/// </summary>
public interface IAddressSpaceSearchable
{
/// <summary>
/// Walks the server's address space breadth-first from the root, bounded by
/// <paramref name="maxDepth"/> and <paramref name="maxResults"/>, returning
/// the nodes whose DisplayName or root-relative path contains
/// <paramref name="query"/> (case-insensitive substring match).
/// </summary>
/// <param name="query">Case-insensitive substring to match against each node's DisplayName and its root-relative path. An empty/whitespace query matches nothing (the walk is skipped).</param>
/// <param name="maxDepth">Maximum number of levels below the root to descend (Object nodes deeper than this are not expanded). Must be non-negative.</param>
/// <param name="maxResults">Maximum number of matches to return; when reached the walk stops early and <see cref="AddressSpaceSearchResult.CapReached"/> is set true.</param>
/// <param name="cancellationToken">Cancellation token; on cancellation the implementation should throw <see cref="OperationCanceledException"/>.</param>
/// <returns>A task that resolves to the matches found and a flag indicating whether a bound (result cap or node-visit ceiling) cut the walk short.</returns>
Task<AddressSpaceSearchResult> SearchAddressSpaceAsync(
string query,
int maxDepth,
int maxResults,
CancellationToken cancellationToken = default);
}
/// <param name="Node">The matched address-space node, carrying its NodeId, DisplayName, NodeClass and (B1) type info.</param>
/// <param name="Path">The matched node's root-relative path: the slash-joined DisplayName chain from the root down to the node (e.g. <c>"/Folder1/Tag1"</c>).</param>
public record AddressSpaceMatch(BrowseNode Node, string Path);
/// <param name="Matches">The matched nodes, in breadth-first discovery order.</param>
/// <param name="CapReached">True when a bound cut the walk short — either <c>maxResults</c> matches were collected or the implementation's node-visit ceiling was hit — so more matches may exist than were returned.</param>
public record AddressSpaceSearchResult(
IReadOnlyList<AddressSpaceMatch> Matches,
bool CapReached);
@@ -148,6 +148,113 @@ public interface IOpcUaClient : IAsyncDisposable
string? parentNodeId,
string? continuationToken = null,
CancellationToken cancellationToken = default);
/// <summary>
/// M7-B4 (T15): walks the server's address space breadth-first from the
/// root (ObjectsFolder), bounded by <paramref name="maxDepth"/> and
/// <paramref name="maxResults"/>, returning the nodes whose DisplayName or
/// root-relative path contains <paramref name="query"/> (case-insensitive
/// substring). Implemented over this client's own
/// <see cref="BrowseChildrenAsync"/> (paging the continuation per node so
/// no children are missed). Throws
/// <see cref="ConnectionNotConnectedException"/> when the session is not
/// currently up. An empty/whitespace query matches nothing.
/// </summary>
/// <param name="query">Case-insensitive substring matched against each node's DisplayName and root-relative path; empty/whitespace matches nothing.</param>
/// <param name="maxDepth">Maximum number of levels below the root to descend.</param>
/// <param name="maxResults">Maximum matches to return; when reached the walk stops early and the result's CapReached flag is set.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that completes with the matches found and a flag indicating whether a bound cut the walk short.</returns>
Task<AddressSpaceSearchResult> SearchAddressSpaceAsync(
string query,
int maxDepth,
int maxResults,
CancellationToken cancellationToken = default);
}
/// <summary>
/// M7-B4 (T15): shared bounded breadth-first address-space search, expressed
/// purely over an injected browse delegate so the stub and real OPC UA clients
/// run the IDENTICAL walk (matching, continuation-paging, depth bound, result
/// cap, node-visit ceiling, cancellation). The delegate is each client's own
/// <see cref="IOpcUaClient.BrowseChildrenAsync"/>.
/// </summary>
internal static class AddressSpaceSearch
{
// Total nodes visited before the walk gives up and reports CapReached, even
// if maxResults was not hit. Guards against a pathological / cyclic address
// space (v1 keeps no visited-set) and runaway breadth. Generous relative to
// any realistic depth+result bound a UI search would request.
internal const int VisitedNodeCeiling = 10_000;
/// <summary>
/// Runs the bounded BFS. <paramref name="browse"/> mirrors
/// <see cref="IBrowsableDataConnection.BrowseChildrenAsync"/>
/// (parentNodeId, continuationToken, ct) → page of children.
/// </summary>
internal static async Task<AddressSpaceSearchResult> SearchAsync(
Func<string?, string?, CancellationToken, Task<BrowseChildrenResult>> browse,
string query,
int maxDepth,
int maxResults,
CancellationToken cancellationToken)
{
// Empty/whitespace query → no walk, no matches (matches nothing).
if (string.IsNullOrWhiteSpace(query) || maxResults <= 0)
return new AddressSpaceSearchResult(Array.Empty<AddressSpaceMatch>(), CapReached: false);
var matches = new List<AddressSpaceMatch>();
var capReached = false;
var visited = 0;
// Queue of (nodeId, pathPrefix, depth). Root = null nodeId, "" prefix,
// depth 0; its children sit at depth 1.
var queue = new Queue<(string? NodeId, string PathPrefix, int Depth)>();
queue.Enqueue((null, string.Empty, 0));
while (queue.Count > 0)
{
cancellationToken.ThrowIfCancellationRequested();
var (nodeId, pathPrefix, depth) = queue.Dequeue();
// Page the continuation per node so no children are missed: the
// first call starts a fresh browse; while a continuation token is
// returned, fetch the next page and keep processing.
string? continuationToken = null;
do
{
cancellationToken.ThrowIfCancellationRequested();
var page = await browse(nodeId, continuationToken, cancellationToken).ConfigureAwait(false);
foreach (var child in page.Children)
{
if (++visited > VisitedNodeCeiling)
return new AddressSpaceSearchResult(matches, CapReached: true);
var path = pathPrefix + "/" + child.DisplayName;
if (Contains(child.DisplayName, query) || Contains(path, query))
{
matches.Add(new AddressSpaceMatch(child, path));
if (matches.Count >= maxResults)
return new AddressSpaceSearchResult(matches, CapReached: true);
}
// Descend into Object nodes within the depth budget. depth is
// the parent's depth; children sit at depth + 1.
if (child.NodeClass == BrowseNodeClass.Object && depth < maxDepth)
queue.Enqueue((child.NodeId, path, depth + 1));
}
continuationToken = page.Truncated ? page.ContinuationToken : null;
}
while (!string.IsNullOrEmpty(continuationToken));
}
return new AddressSpaceSearchResult(matches, capReached);
}
private static bool Contains(string haystack, string needle) =>
haystack.Contains(needle, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
@@ -284,6 +391,16 @@ internal class StubOpcUaClient : IOpcUaClient
return Task.FromResult(new BrowseChildrenResult(Array.Empty<BrowseNode>(), Truncated: false));
}
/// <inheritdoc />
public Task<AddressSpaceSearchResult> SearchAddressSpaceAsync(
string query, int maxDepth, int maxResults, CancellationToken cancellationToken = default)
// Search the canned tree by walking the stub's OWN BrowseChildrenAsync via
// the shared BFS — identical algorithm to RealOpcUaClient. Because the
// helper pages Folder1's continuation, a "Tag" query finds both Tag1 (page 1)
// and Tag2 (page 2).
=> AddressSpaceSearch.SearchAsync(
BrowseChildrenAsync, query, maxDepth, maxResults, cancellationToken);
/// <summary>Disposes this stub client and marks the connection as closed.</summary>
/// <returns>A completed <see cref="ValueTask"/>.</returns>
public ValueTask DisposeAsync()
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
/// - Read/Write → Read/Write service calls
/// - Quality → OPC UA StatusCode mapping
/// </summary>
public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection
public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IAlarmSubscribableConnection, IAddressSpaceSearchable
{
private readonly IOpcUaClientFactory _clientFactory;
private readonly ILogger<OpcUaDataConnection> _logger;
@@ -302,6 +302,19 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IA
CancellationToken cancellationToken = default)
=> _client!.BrowseChildrenAsync(parentNodeId, continuationToken, cancellationToken);
/// <inheritdoc />
public Task<AddressSpaceSearchResult> SearchAddressSpaceAsync(
string query, int maxDepth, int maxResults, CancellationToken cancellationToken = default)
{
// Guard connection state with the typed ConnectionNotConnectedException so
// the site-side handler can map it uniformly (mirrors BrowseChildrenAsync,
// whose underlying client throws the same type when the session is down).
if (_client == null || !_client.IsConnected)
throw new ConnectionNotConnectedException("OPC UA client is not connected.");
return _client.SearchAddressSpaceAsync(query, maxDepth, maxResults, cancellationToken);
}
/// <inheritdoc />
public async Task<bool> WriteBatchAndWaitAsync(
IDictionary<string, object?> values, string flagPath, object? flagValue,
@@ -776,6 +776,28 @@ public class RealOpcUaClient : IOpcUaClient
}
}
/// <inheritdoc />
public Task<Commons.Interfaces.Protocol.AddressSpaceSearchResult> SearchAddressSpaceAsync(
string query, int maxDepth, int maxResults, CancellationToken cancellationToken = default)
{
// Fail fast with the typed exception when the link is down — mirrors the
// BrowseChildrenAsync guard. (BrowseChildrenAsync would also throw on the
// first page, but guarding here keeps the empty-query / cap-0 short-circuit
// from masking a disconnected session.)
var session = _session;
if (session is null || !session.Connected)
{
throw new Commons.Interfaces.Protocol.ConnectionNotConnectedException(
"OPC UA session is not connected.");
}
// Bounded BFS over this client's OWN BrowseChildrenAsync — the shared
// helper pages each node's continuation (BrowseNext) to the end, so a
// big folder's later pages are searched too, not just the first page.
return AddressSpaceSearch.SearchAsync(
BrowseChildrenAsync, query, maxDepth, maxResults, cancellationToken);
}
/// <summary>
/// Issues a fresh <see cref="SessionClientExtensions.BrowseAsync"/> of
/// <paramref name="nodeToBrowse"/> and returns its first page, surfacing any