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
@@ -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