From 2cfe0de92713a05906cd261645fb591da14601e7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 02:21:59 -0400 Subject: [PATCH] feat(dcl): BrowseNext continuation paging + StubOpcUaClient canned browse (T15) --- .../Protocol/IBrowsableDataConnection.cs | 15 ++- .../Adapters/IOpcUaClient.cs | 54 ++++++++- .../Adapters/MxGatewayDataConnection.cs | 8 +- .../Adapters/OpcUaDataConnection.cs | 3 +- .../Adapters/RealOpcUaClient.cs | 111 ++++++++++++++---- ...DataConnectionManagerBrowseHandlerTests.cs | 8 +- .../OpcUaDataConnectionBrowseTests.cs | 4 +- .../StubOpcUaClientBrowseTests.cs | 92 +++++++++++++++ 8 files changed, 258 insertions(+), 37 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientBrowseTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs index 87f953af..0b9d4208 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Protocol/IBrowsableDataConnection.cs @@ -10,21 +10,28 @@ public interface IBrowsableDataConnection { /// /// Returns the immediate children of , 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 , + /// pass it back via to fetch the next + /// page; a null/empty token starts a fresh browse of . /// /// Node id whose children to browse, or null for the server root (OPC UA ObjectsFolder). + /// Opaque token from a prior 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. /// Cancellation token; on cancellation the implementation should throw . - /// A task that resolves to the child nodes and a flag indicating whether results were truncated. + /// 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). Task BrowseChildrenAsync( string? parentNodeId, + string? continuationToken = null, CancellationToken cancellationToken = default); } /// Child nodes returned by the server in browse order. -/// True when the server reported more children than the per-call cap; remaining children must be discovered via manual entry. +/// True when more children remain beyond this page; fetch them by passing back to . +/// Opaque token to fetch the next page (echo it back via the continuationToken parameter), or null when the browse is exhausted. Implementation-private — callers must treat it as opaque. public record BrowseChildrenResult( IReadOnlyList Children, - bool Truncated); + bool Truncated, + string? ContinuationToken = null); /// Server-issued node identifier (e.g. "ns=2;s=Devices.Pump1.Speed"). /// Human-readable display name from the server's DisplayName attribute. diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs index e4cc7665..eda70ce8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/IOpcUaClient.cs @@ -132,15 +132,21 @@ public interface IOpcUaClient : IAsyncDisposable /// /// Enumerates the immediate children of - /// (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 + /// , pass it back via + /// to fetch the next page (BrowseNext); + /// a null/empty token starts a fresh browse. Throws /// when the session is not /// currently up. /// /// Node id whose children to browse, or null for the server root. + /// Opaque token from a prior to fetch the next page via BrowseNext; null/empty starts a fresh browse. /// A cancellation token that can be used to cancel the operation. - /// A task that completes with the immediate children of the requested node. + /// A task that completes with the immediate children of the requested node and a continuation token for the next page (null when exhausted). Task BrowseChildrenAsync( string? parentNodeId, + string? continuationToken = null, CancellationToken cancellationToken = default); } @@ -235,8 +241,48 @@ internal class StubOpcUaClient : IOpcUaClient /// public Task 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(), Truncated: false)); + } /// Disposes this stub client and marks the connection as closed. /// A completed . diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs index 2e6ec9d4..571c74f4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/MxGatewayDataConnection.cs @@ -254,11 +254,17 @@ public class MxGatewayDataConnection : IDataConnection, IBrowsableDataConnection } /// - public async Task BrowseChildrenAsync(string? parentNodeId, CancellationToken cancellationToken = default) + public async Task 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)) diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs index b8451e56..cf580725 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaDataConnection.cs @@ -298,8 +298,9 @@ public class OpcUaDataConnection : IDataConnection, IBrowsableDataConnection, IA /// public Task BrowseChildrenAsync( string? parentNodeId, + string? continuationToken = null, CancellationToken cancellationToken = default) - => _client!.BrowseChildrenAsync(parentNodeId, cancellationToken); + => _client!.BrowseChildrenAsync(parentNodeId, continuationToken, cancellationToken); /// public async Task WriteBatchAndWaitAsync( diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs index aa65b2c0..c0a781e1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -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); + /// public async Task 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 results, IList 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); + } + } + + /// + /// Issues a fresh of + /// and returns its first page, surfacing any + /// continuation point as a token for paging. + /// + private async Task 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); + } + + /// + /// 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 + /// is surfaced as a Base64 + /// ContinuationToken with Truncated=true; otherwise the result + /// is exhausted (ContinuationToken=null, Truncated=false). + /// + private async Task BuildResultAsync( + ISession session, + ReferenceDescriptionCollection? references, + byte[]? continuationPoint, + CancellationToken cancellationToken) + { var refs = references ?? new ReferenceDescriptionCollection(); var children = new List(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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs index b88ad9b0..34526b2d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Actors/DataConnectionManagerBrowseHandlerTests.cs @@ -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()) + .BrowseChildrenAsync(null, Arg.Any(), Arg.Any()) .Returns(new BrowseChildrenResult(children, Truncated: false)); _factory.Create("OpcUa", Arg.Any>()) @@ -139,7 +139,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected); ((IBrowsableDataConnection)adapter) - .BrowseChildrenAsync(Arg.Any(), Arg.Any()) + .BrowseChildrenAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromException( 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(), Arg.Any()) + .BrowseChildrenAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromException(new NotSupportedException(reason))); _factory.Create("MxGateway", Arg.Any>()) @@ -226,7 +226,7 @@ public class DataConnectionManagerBrowseHandlerTests : TestKit .Returns(Task.CompletedTask); ((IDataConnection)adapter).Status.Returns(ConnectionHealth.Connected); ((IBrowsableDataConnection)adapter) - .BrowseChildrenAsync(null, Arg.Any()) + .BrowseChildrenAsync(null, Arg.Any(), Arg.Any()) .Returns(new BrowseChildrenResult(bigList, Truncated: false)); _factory.Create("OpcUa", Arg.Any>()) diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs index 0d0e06fd..9f29d947 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaDataConnectionBrowseTests.cs @@ -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()) + client.BrowseChildrenAsync("ns=2;s=Parent", Arg.Any(), Arg.Any()) .Returns(expected); var adapter = new OpcUaDataConnection(factory, NullLogger.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()); + await client.Received(1).BrowseChildrenAsync("ns=2;s=Parent", Arg.Any(), Arg.Any()); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientBrowseTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientBrowseTests.cs new file mode 100644 index 00000000..986d2bee --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/StubOpcUaClientBrowseTests.cs @@ -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; + +/// +/// M7-B2 (T15): 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 Folder1 so the +/// continuation-token round-trip is testable end-to-end against the contract. +/// +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 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); + } +}