feat(dcl): BrowseNext continuation paging + StubOpcUaClient canned browse (T15)

This commit is contained in:
Joseph Doherty
2026-06-18 02:21:59 -04:00
parent 3c9122bc07
commit 2cfe0de927
8 changed files with 258 additions and 37 deletions
@@ -10,21 +10,28 @@ public interface IBrowsableDataConnection
{
/// <summary>
/// Returns the immediate children of <paramref name="parentNodeId"/>, or
/// the server's root-level nodes when null.
/// the server's root-level nodes when null. Pageable: when a prior call
/// returned a non-null <see cref="BrowseChildrenResult.ContinuationToken"/>,
/// pass it back via <paramref name="continuationToken"/> to fetch the next
/// page; a null/empty token starts a fresh browse of <paramref name="parentNodeId"/>.
/// </summary>
/// <param name="parentNodeId">Node id whose children to browse, or null for the server root (OPC UA ObjectsFolder).</param>
/// <param name="continuationToken">Opaque token from a prior <see cref="BrowseChildrenResult.ContinuationToken"/> to fetch the next page; null/empty starts a fresh browse. The token is implementation-private (e.g. a Base64 OPC UA continuation point) and must not be interpreted by callers.</param>
/// <param name="cancellationToken">Cancellation token; on cancellation the implementation should throw <see cref="OperationCanceledException"/>.</param>
/// <returns>A task that resolves to the child nodes and a flag indicating whether results were truncated.</returns>
/// <returns>A task that resolves to the child nodes, a flag indicating whether more results remain, and a continuation token to fetch the next page (null when exhausted).</returns>
Task<BrowseChildrenResult> BrowseChildrenAsync(
string? parentNodeId,
string? continuationToken = null,
CancellationToken cancellationToken = default);
}
/// <param name="Children">Child nodes returned by the server in browse order.</param>
/// <param name="Truncated">True when the server reported more children than the per-call cap; remaining children must be discovered via manual entry.</param>
/// <param name="Truncated">True when more children remain beyond this page; fetch them by passing <paramref name="ContinuationToken"/> back to <see cref="IBrowsableDataConnection.BrowseChildrenAsync"/>.</param>
/// <param name="ContinuationToken">Opaque token to fetch the next page (echo it back via the <c>continuationToken</c> parameter), or null when the browse is exhausted. Implementation-private — callers must treat it as opaque.</param>
public record BrowseChildrenResult(
IReadOnlyList<BrowseNode> Children,
bool Truncated);
bool Truncated,
string? ContinuationToken = null);
/// <param name="NodeId">Server-issued node identifier (e.g. <c>"ns=2;s=Devices.Pump1.Speed"</c>).</param>
/// <param name="DisplayName">Human-readable display name from the server's DisplayName attribute.</param>
@@ -132,15 +132,21 @@ public interface IOpcUaClient : IAsyncDisposable
/// <summary>
/// Enumerates the immediate children of <paramref name="parentNodeId"/>
/// (or the server's ObjectsFolder when null). Throws
/// (or the server's ObjectsFolder when null). Pageable via the OPC UA
/// continuation-point mechanism: when a prior call returned a non-null
/// <see cref="BrowseChildrenResult.ContinuationToken"/>, pass it back via
/// <paramref name="continuationToken"/> to fetch the next page (BrowseNext);
/// a null/empty token starts a fresh browse. Throws
/// <see cref="ConnectionNotConnectedException"/> when the session is not
/// currently up.
/// </summary>
/// <param name="parentNodeId">Node id whose children to browse, or null for the server root.</param>
/// <param name="continuationToken">Opaque token from a prior <see cref="BrowseChildrenResult.ContinuationToken"/> to fetch the next page via BrowseNext; null/empty starts a fresh browse.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A task that completes with the immediate children of the requested node.</returns>
/// <returns>A task that completes with the immediate children of the requested node and a continuation token for the next page (null when exhausted).</returns>
Task<BrowseChildrenResult> BrowseChildrenAsync(
string? parentNodeId,
string? continuationToken = null,
CancellationToken cancellationToken = default);
}
@@ -235,8 +241,48 @@ internal class StubOpcUaClient : IOpcUaClient
/// <inheritdoc />
public Task<BrowseChildrenResult> BrowseChildrenAsync(
string? parentNodeId, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default)
{
// Canned address-space tree so DCL browse/paging flows can be exercised
// without a live OPC UA server (T15):
// (root)
// ├─ Folder1 (Object, HasChildren=true)
// │ ├─ Tag1 (Variable) ← page 1
// │ └─ Tag2 (Variable) ← page 2 (continuation token "STUB_PAGE2")
// └─ Folder2 (Object, HasChildren=false, leaf)
// Folder1 fakes BrowseNext continuation paging so the continuation-token
// round-trip is testable; everything else is a single, exhausted page.
if (string.IsNullOrEmpty(parentNodeId))
{
return Task.FromResult(new BrowseChildrenResult(
new[]
{
new BrowseNode("Folder1", "Folder1", BrowseNodeClass.Object, HasChildren: true),
new BrowseNode("Folder2", "Folder2", BrowseNodeClass.Object, HasChildren: false),
},
Truncated: false));
}
if (parentNodeId == "Folder1")
{
// Page 2: the caller passed back the continuation token from page 1.
if (continuationToken == "STUB_PAGE2")
{
return Task.FromResult(new BrowseChildrenResult(
new[] { new BrowseNode("Tag2", "Tag2", BrowseNodeClass.Variable, HasChildren: false) },
Truncated: false));
}
// Page 1: return only Tag1 and a continuation token for the next page.
return Task.FromResult(new BrowseChildrenResult(
new[] { new BrowseNode("Tag1", "Tag1", BrowseNodeClass.Variable, HasChildren: false) },
Truncated: true,
ContinuationToken: "STUB_PAGE2"));
}
// Leaves (Folder2, Variable nodes, anything unknown) have no children.
return Task.FromResult(new BrowseChildrenResult(Array.Empty<BrowseNode>(), Truncated: false));
}
/// <summary>Disposes this stub client and marks the connection as closed.</summary>
/// <returns>A completed <see cref="ValueTask"/>.</returns>
@@ -254,11 +254,17 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection
}
/// <inheritdoc />
public async Task<BrowseChildrenResult> BrowseChildrenAsync(string? parentNodeId, CancellationToken cancellationToken = default)
public async Task<BrowseChildrenResult> BrowseChildrenAsync(
string? parentNodeId,
string? continuationToken = null,
CancellationToken cancellationToken = default)
{
if (_status != ConnectionHealth.Connected || _client is null)
throw new ConnectionNotConnectedException($"MxGateway connection is not connected (status: {_status}).");
// MxGateway browse is not pageable — the gateway returns a single,
// already-bounded child set per node. Any continuation token is ignored
// and no continuation is ever surfaced (ContinuationToken stays null).
var (children, truncated) = await _client.BrowseChildrenAsync(parentNodeId, cancellationToken);
var nodes = children
.Select(c => new BrowseNode(c.NodeId, c.DisplayName, c.NodeClass, c.HasChildren))
@@ -298,8 +298,9 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IA
/// <inheritdoc />
public Task<BrowseChildrenResult> BrowseChildrenAsync(
string? parentNodeId,
string? continuationToken = null,
CancellationToken cancellationToken = default)
=> _client!.BrowseChildrenAsync(parentNodeId, cancellationToken);
=> _client!.BrowseChildrenAsync(parentNodeId, continuationToken, cancellationToken);
/// <inheritdoc />
public async Task<bool> WriteBatchAndWaitAsync(
@@ -701,9 +701,22 @@ public class RealOpcUaClient : IOpcUaClient
? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf)
: configured;
// requestedMaxReferencesPerNode: cap the server's per-call references so a
// huge flat folder cannot return an unbounded set. 500 leaves headroom for
// the downstream frame-size budget (DataConnectionActor.CapBrowseChildren)
// even with long string NodeIds; a non-empty continuation point surfaces as
// a ContinuationToken so the caller can page via BrowseNext (T15).
private const uint BrowseMaxReferencesPerNode = 500u;
// NodeClassMask intentionally excludes ReferenceType, View, Variable-
// Type, ObjectType, DataType. UI only needs Objects (navigable),
// Variables (selectable), Methods (display-only).
private const uint BrowseNodeClassMask =
(uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method);
/// <inheritdoc />
public async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BrowseChildrenAsync(
string? parentNodeId, CancellationToken cancellationToken = default)
string? parentNodeId, string? continuationToken = null, CancellationToken cancellationToken = default)
{
// Mirror the SubscribeAsync/ReadAsync wrap idiom: snapshot the session
// reference once, fail fast with a typed exception if the link is
@@ -723,27 +736,83 @@ public class RealOpcUaClient : IOpcUaClient
? ObjectIds.ObjectsFolder
: NodeId.Parse(parentNodeId);
// NodeClassMask intentionally excludes ReferenceType, View, Variable-
// Type, ObjectType, DataType. UI only needs Objects (navigable),
// Variables (selectable), Methods (display-only).
var nodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable | NodeClass.Method);
// No token → fresh browse of the node. A non-empty token → continue a
// prior browse via BrowseNext, falling back to a fresh browse if the
// server has invalidated the continuation point.
if (string.IsNullOrEmpty(continuationToken))
{
return await FreshBrowseAsync(session, nodeToBrowse, cancellationToken).ConfigureAwait(false);
}
// requestedMaxReferencesPerNode: cap the server's per-call references so a
// huge flat folder cannot return an unbounded set. 500 leaves headroom for
// the downstream frame-size budget (DataConnectionActor.CapBrowseChildren)
// even with long string NodeIds; a non-empty continuation point surfaces as
// Truncated, prompting manual entry rather than auto-paging.
try
{
// SDK overload: BrowseNextAsync(session, requestHeader, ByteStringCollection
// continuationPoints, bool releaseContinuationPoint, ct) → returns
// (ResponseHeader, ByteStringCollection revisedContinuationPoints,
// IList<ReferenceDescriptionCollection> results, IList<ServiceResult> errors).
// releaseContinuationPoint:false keeps the point alive so the next page
// can be fetched; we pass back exactly the one point we were handed.
var (_, revisedPoints, results, _) = await session.BrowseNextAsync(
null,
new ByteStringCollection { Convert.FromBase64String(continuationToken) },
false,
cancellationToken).ConfigureAwait(false);
var nextPoint = revisedPoints is { Count: > 0 } ? revisedPoints[0] : null;
var refs = results is { Count: > 0 } ? results[0] : null;
return await BuildResultAsync(session, refs, nextPoint, cancellationToken).ConfigureAwait(false);
}
catch (ServiceResultException ex) when (
ex.StatusCode == StatusCodes.BadContinuationPointInvalid ||
ex.StatusCode == StatusCodes.BadInvalidArgument)
{
// The continuation point expired or was rejected (e.g. a fresh
// session, or the server timed it out). Recover by re-browsing the
// parent from the start and returning its first page.
_logger.LogDebug(ex,
"OPC UA BrowseNext rejected the continuation point (status {Status:X8}); " +
"falling back to a fresh browse of {Node}.", ex.StatusCode, nodeToBrowse);
return await FreshBrowseAsync(session, nodeToBrowse, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Issues a fresh <see cref="SessionClientExtensions.BrowseAsync"/> of
/// <paramref name="nodeToBrowse"/> and returns its first page, surfacing any
/// continuation point as a token for paging.
/// </summary>
private async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> FreshBrowseAsync(
ISession session, NodeId nodeToBrowse, CancellationToken cancellationToken)
{
var (_, continuationPoint, references) = await session.BrowseAsync(
null,
null,
nodeToBrowse,
500u,
BrowseMaxReferencesPerNode,
BrowseDirection.Forward,
ReferenceTypeIds.HierarchicalReferences,
true,
nodeClassMask,
BrowseNodeClassMask,
cancellationToken).ConfigureAwait(false);
return await BuildResultAsync(session, references, continuationPoint, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Shared "build children + B1 type enrichment + continuation token" logic
/// used by both the initial browse and the BrowseNext paths so the
/// enrichment is never duplicated (T15). A non-empty
/// <paramref name="continuationPoint"/> is surfaced as a Base64
/// <c>ContinuationToken</c> with <c>Truncated=true</c>; otherwise the result
/// is exhausted (<c>ContinuationToken=null, Truncated=false</c>).
/// </summary>
private async Task<Commons.Interfaces.Protocol.BrowseChildrenResult> BuildResultAsync(
ISession session,
ReferenceDescriptionCollection? references,
byte[]? continuationPoint,
CancellationToken cancellationToken)
{
var refs = references ?? new ReferenceDescriptionCollection();
var children = new List<Commons.Interfaces.Protocol.BrowseNode>(refs.Count);
foreach (var r in refs)
@@ -755,22 +824,22 @@ public class RealOpcUaClient : IOpcUaClient
HasChildren: r.NodeClass == NodeClass.Object));
}
// T16 type-info: enrich Variable rows with DataType / ValueRank /
// B1 type-info: enrich Variable rows with DataType / ValueRank /
// Writable so the node picker can show types. Best-effort: ONE batched
// ReadAsync over (DataType, ValueRank, UserAccessLevel) for every
// Variable child; on ANY failure we leave the three fields null and
// return the children exactly as built above. Non-Variable nodes are
// never read and keep null type info.
// never read and keep null type info. Runs identically on the browse
// and browse-next pages (T15 shares this path).
children = await EnrichVariableTypeInfoAsync(session, refs, children, cancellationToken)
.ConfigureAwait(false);
// A non-empty continuation point means the server had more refs than
// our requestedMaxReferencesPerNode cap. The UI surfaces a "more
// children, type the node id manually" hint rather than auto-paging;
// BrowseNext is not invoked here. Discarding the continuation point
// is acceptable because the server expires it on session close.
var truncated = continuationPoint != null && continuationPoint.Length > 0;
return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, truncated);
// A non-empty continuation point means the server has more refs than
// were returned on this page. Surface it as an opaque Base64 token the
// caller passes back to fetch the next page via BrowseNext (T15).
var hasMore = continuationPoint != null && continuationPoint.Length > 0;
var token = hasMore ? Convert.ToBase64String(continuationPoint!) : null;
return new Commons.Interfaces.Protocol.BrowseChildrenResult(children, hasMore, token);
}
private static Commons.Interfaces.Protocol.BrowseNodeClass MapNodeClass(NodeClass nc) => nc switch
@@ -105,7 +105,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
new BrowseNode("ns=2;s=B", "B", BrowseNodeClass.Object, HasChildren: true),
};
((IBrowsableDataConnection)adapter)
.BrowseChildrenAsync(null, Arg.Any<CancellationToken>())
.BrowseChildrenAsync(null, Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new BrowseChildrenResult(children, Truncated: false));
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
@@ -139,7 +139,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
((IBrowsableDataConnection)adapter)
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<BrowseChildrenResult>(
new ConnectionNotConnectedException("OPC UA session is not connected.")));
@@ -183,7 +183,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
((IBrowsableDataConnection)adapter)
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
.BrowseChildrenAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<BrowseChildrenResult>(new NotSupportedException(reason)));
_factory.Create("MxGateway", Arg.Any<IDictionary<string, string>>())
@@ -226,7 +226,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit
.Returns(Task.CompletedTask);
((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected);
((IBrowsableDataConnection)adapter)
.BrowseChildrenAsync(null, Arg.Any<CancellationToken>())
.BrowseChildrenAsync(null, Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(new BrowseChildrenResult(bigList, Truncated: false));
_factory.Create("OpcUa", Arg.Any<IDictionary<string, string>>())
@@ -25,7 +25,7 @@ public class OpcUaDataConnectionBrowseTests
var expected = new BrowseChildrenResult(
new[] { new BrowseNode("ns=2;s=X", "X", BrowseNodeClass.Variable, false) },
Truncated: false);
client.BrowseChildrenAsync("ns=2;s=Parent", Arg.Any<CancellationToken>())
client.BrowseChildrenAsync("ns=2;s=Parent", Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(expected);
var adapter = new OpcUaDataConnection(factory, NullLogger<OpcUaDataConnection>.Instance);
@@ -36,6 +36,6 @@ public class OpcUaDataConnectionBrowseTests
var actual = await adapter.BrowseChildrenAsync("ns=2;s=Parent");
Assert.Same(expected, actual);
await client.Received(1).BrowseChildrenAsync("ns=2;s=Parent", Arg.Any<CancellationToken>());
await client.Received(1).BrowseChildrenAsync("ns=2;s=Parent", Arg.Any<string?>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,92 @@
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
/// <summary>
/// M7-B2 (T15): <see cref="StubOpcUaClient"/> serves a small canned address-space
/// tree so DCL browse/paging flows can be exercised without a live OPC UA server.
/// The stub also fakes BrowseNext continuation paging on <c>Folder1</c> so the
/// continuation-token round-trip is testable end-to-end against the contract.
/// </summary>
public class StubOpcUaClientBrowseTests
{
// StubOpcUaClient is internal; reached here via InternalsVisibleTo on the
// DataConnectionLayer assembly (the test project is already a friend, since
// other tests in this project new up internal adapter types).
private static async Task<IOpcUaClient> ConnectedStubAsync()
{
var client = new StubOpcUaClient();
await client.ConnectAsync("opc.tcp://stub", null, CancellationToken.None);
return client;
}
[Fact]
public async Task BrowseRoot_ReturnsTwoFolders_NoContinuation()
{
await using var client = (StubOpcUaClient)await ConnectedStubAsync();
var result = await client.BrowseChildrenAsync(parentNodeId: null);
Assert.False(result.Truncated);
Assert.Null(result.ContinuationToken);
Assert.Collection(
result.Children,
n =>
{
Assert.Equal("Folder1", n.DisplayName);
Assert.Equal(BrowseNodeClass.Object, n.NodeClass);
Assert.True(n.HasChildren);
},
n =>
{
Assert.Equal("Folder2", n.DisplayName);
Assert.Equal(BrowseNodeClass.Object, n.NodeClass);
Assert.False(n.HasChildren);
});
}
[Fact]
public async Task BrowseFolder1_NoToken_ReturnsTag1_AndContinuationToken()
{
await using var client = (StubOpcUaClient)await ConnectedStubAsync();
var result = await client.BrowseChildrenAsync(parentNodeId: "Folder1");
Assert.True(result.Truncated);
Assert.Equal("STUB_PAGE2", result.ContinuationToken);
var only = Assert.Single(result.Children);
Assert.Equal("Tag1", only.DisplayName);
Assert.Equal(BrowseNodeClass.Variable, only.NodeClass);
Assert.False(only.HasChildren);
}
[Fact]
public async Task BrowseFolder1_WithContinuationToken_ReturnsTag2_AndNoFurtherContinuation()
{
await using var client = (StubOpcUaClient)await ConnectedStubAsync();
var result = await client.BrowseChildrenAsync(
parentNodeId: "Folder1",
continuationToken: "STUB_PAGE2");
Assert.False(result.Truncated);
Assert.Null(result.ContinuationToken);
var only = Assert.Single(result.Children);
Assert.Equal("Tag2", only.DisplayName);
Assert.Equal(BrowseNodeClass.Variable, only.NodeClass);
Assert.False(only.HasChildren);
}
[Fact]
public async Task BrowseLeaf_ReturnsEmpty_NoContinuation()
{
await using var client = (StubOpcUaClient)await ConnectedStubAsync();
var result = await client.BrowseChildrenAsync(parentNodeId: "Folder2");
Assert.False(result.Truncated);
Assert.Null(result.ContinuationToken);
Assert.Empty(result.Children);
}
}