feat(browse): Wave-0 Batch C — CapturingAddressSpaceBuilder + CapturedTreeBrowseSession

Task 6: CapturedTree/CapturedNode model + CapturingAddressSpaceBuilder — an in-memory
        IAddressSpaceBuilder that records a driver's DiscoverAsync stream (folder nesting,
        leaf FullName as node id, alarm mark, node-cap truncation, no-op alarm sink).
Task 7: CapturedTreeBrowseSession — serves the captured snapshot through IBrowseSession
        (Root/Expand/Attributes from memory, truncation marker, shared-immutable-tree dispose).
13 unit tests green.
This commit is contained in:
Joseph Doherty
2026-07-15 17:22:00 -04:00
parent 679484ae78
commit 15da8d4f0d
6 changed files with 419 additions and 2 deletions
@@ -0,0 +1,49 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
/// <summary>One node captured from a driver's DiscoverAsync stream. Folder ids are
/// path-composed ("/parent/child"); leaf ids are the driver FullName (the commit value).</summary>
public sealed class CapturedNode
{
/// <summary>Stable identifier. Folder = path-composed ("/parent/child"); leaf = driver FullName.</summary>
public required string Id { get; init; }
/// <summary>Browse name (the path segment under the parent).</summary>
public required string Name { get; init; }
/// <summary>Human-readable display name.</summary>
public required string DisplayName { get; init; }
/// <summary>True for a variable (terminal); false for a folder.</summary>
public required bool IsLeaf { get; init; }
/// <summary>Driver-side attribute metadata; set only on leaves.</summary>
public DriverAttributeInfo? Attribute { get; init; }
/// <summary>True when the driver marked this leaf as an alarm condition during discovery.</summary>
public bool IsAlarmMarked { get; set; }
/// <summary>Captured child nodes (folders first-or-interleaved in stream order).</summary>
public List<CapturedNode> Children { get; } = [];
/// <summary>Captured static properties attached to this node via AddProperty.</summary>
public List<(string Name, DriverDataType Type, object? Value)> Properties { get; } = [];
}
/// <summary>Immutable-after-Build snapshot of a whole discovery capture. May be shared by
/// multiple coalesced browse sessions — sessions drop their reference on dispose, never mutate.</summary>
public sealed class CapturedTree
{
/// <summary>Synthetic root; its Children are the top-level captured nodes.</summary>
public required CapturedNode Root { get; init; }
/// <summary>True when the node cap was hit and recording stopped short.</summary>
public required bool Truncated { get; init; }
/// <summary>Number of nodes actually recorded (bounded by the node cap).</summary>
public required int Count { get; init; }
/// <summary>Flat lookup from node id to node (for O(1) Expand/Attributes serving).</summary>
public required IReadOnlyDictionary<string, CapturedNode> ById { get; init; }
}
@@ -0,0 +1,71 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
/// <summary>
/// Serves a captured discovery snapshot through the standard picker session interface.
/// No I/O after open; the tree is point-in-time (picker offers Refresh = close + re-open).
/// The tree may be shared by coalesced sessions — dispose drops only this session's
/// reference. See design §4.3.
/// </summary>
public sealed class CapturedTreeBrowseSession(CapturedTree tree) : IBrowseSession
{
private CapturedTree? _tree = tree;
/// <inheritdoc />
public Guid Token { get; } = Guid.NewGuid();
/// <inheritdoc />
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
{
var t = Tree();
var nodes = t.Root.Children.Select(Map).ToList();
if (t.Truncated)
nodes.Add(new BrowseNode("__truncated__",
"⚠ Results truncated — narrow the driver config or use manual entry",
BrowseNodeKind.Folder, HasChildrenHint: false));
LastUsedUtc = DateTime.UtcNow;
return Task.FromResult<IReadOnlyList<BrowseNode>>(nodes);
}
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
{
var t = Tree();
LastUsedUtc = DateTime.UtcNow;
return Task.FromResult<IReadOnlyList<BrowseNode>>(
t.ById.TryGetValue(nodeId, out var n) ? n.Children.Select(Map).ToList() : []);
}
/// <inheritdoc />
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
{
var t = Tree();
LastUsedUtc = DateTime.UtcNow;
if (!t.ById.TryGetValue(nodeId, out var n))
return Task.FromResult<IReadOnlyList<AttributeInfo>>([]);
var attrs = new List<AttributeInfo>();
if (n.Attribute is { } a)
attrs.Add(new AttributeInfo(n.Name, a.DriverDataType.ToString(), a.IsArray,
a.SecurityClass.ToString(), a.IsAlarm || n.IsAlarmMarked));
attrs.AddRange(n.Properties.Select(p =>
new AttributeInfo(p.Name, p.Type.ToString(), false, "", false)));
return Task.FromResult<IReadOnlyList<AttributeInfo>>(attrs);
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
_tree = null; // drop this session's reference only — tree may be shared (coalescing)
return ValueTask.CompletedTask;
}
private CapturedTree Tree() => _tree ?? throw new ObjectDisposedException(nameof(CapturedTreeBrowseSession));
private static BrowseNode Map(CapturedNode n) => n.IsLeaf
? new BrowseNode(n.Id, n.DisplayName, BrowseNodeKind.Leaf, false)
: new BrowseNode(n.Id, n.DisplayName, BrowseNodeKind.Folder, n.Children.Count > 0);
}
@@ -0,0 +1,112 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Browsing;
/// <summary>
/// In-memory <see cref="IAddressSpaceBuilder"/> that records the streamed discovery tree
/// for the universal browser instead of materializing OPC UA nodes. Single-threaded like
/// every builder a driver streams into during DiscoverAsync. Enforces a node cap: once
/// exceeded it stops recording (drivers keep streaming harmlessly) and marks the tree
/// truncated. See docs/plans/2026-07-15-universal-discovery-browser-design.md §4.1.
/// </summary>
public sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
{
private sealed class CaptureState(int nodeCap)
{
public int Count;
public bool Truncated;
public readonly int NodeCap = nodeCap;
public readonly Dictionary<string, CapturedNode> ById = new(StringComparer.Ordinal);
public bool TryReserve()
{
if (Count >= NodeCap) { Truncated = true; return false; }
Count++;
return true;
}
}
private readonly CaptureState _state;
private readonly CapturedNode _scope;
/// <summary>Create a root capturing builder with the given node cap (default 50 000).</summary>
/// <param name="nodeCap">Maximum number of nodes recorded before the tree is marked truncated.</param>
public CapturingAddressSpaceBuilder(int nodeCap = 50_000)
{
_state = new CaptureState(nodeCap);
_scope = new CapturedNode { Id = "", Name = "", DisplayName = "", IsLeaf = false };
}
private CapturingAddressSpaceBuilder(CaptureState state, CapturedNode scope)
{
_state = state;
_scope = scope;
}
/// <inheritdoc />
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
var node = new CapturedNode
{
Id = _scope.Id + "/" + browseName,
Name = browseName,
DisplayName = displayName,
IsLeaf = false,
};
if (_state.TryReserve())
{
_scope.Children.Add(node);
_state.ById.TryAdd(node.Id, node);
}
// Over cap: the child builder writes into a detached node — recording stops, streaming doesn't break.
return new CapturingAddressSpaceBuilder(_state, node);
}
/// <inheritdoc />
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
var node = new CapturedNode
{
Id = attributeInfo.FullName,
Name = browseName,
DisplayName = displayName,
IsLeaf = true,
Attribute = attributeInfo,
};
if (_state.TryReserve())
{
_scope.Children.Add(node);
_state.ById.TryAdd(node.Id, node);
}
return new CapturedVariableHandle(node);
}
/// <inheritdoc />
public void AddProperty(string browseName, DriverDataType dataType, object? value) =>
_scope.Properties.Add((browseName, dataType, value));
/// <summary>Snapshot the captured tree. Only valid on the root builder (the one the browser constructed).</summary>
/// <returns>An immutable-after-build <see cref="CapturedTree"/>.</returns>
public CapturedTree Build() => new()
{
Root = _scope,
Truncated = _state.Truncated,
Count = _state.Count,
ById = _state.ById,
};
private sealed class CapturedVariableHandle(CapturedNode node) : IVariableHandle
{
public string FullReference => node.Id;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
node.IsAlarmMarked = true;
return NoopAlarmSink.Instance;
}
}
private sealed class NoopAlarmSink : IAlarmConditionSink
{
public static readonly NoopAlarmSink Instance = new();
public void OnTransition(AlarmEventArgs args) { } // browse never delivers live transitions
}
}