feat(browse): Wave-0 Batch F — capture coalescing/cap + AddAdminUI DI registration

Task 10: in-flight coalescing (Lazy<Task<CapturedTree>> keyed on driverType+SHA256(config))
         so repeat Browse clicks share one device walk; global SemaphoreSlim cap
         (MaxConcurrentCaptures=4) queues excess; per-caller ct via WaitAsync leaves the shared
         capture (bounded by open-timeout, CancellationToken.None) unaffected; in-flight-only map
         (Refresh = fresh capture). 7 concurrency tests green (158 total in Commons.Tests).
Task 12: AddAdminUI registers IUniversalDriverBrowser->DiscoveryDriverBrowser with
         NullDriverFactory fallback (standalone AdminUI => CanBrowse false => manual entry).
         AdminUI.Tests 542 green.
This commit is contained in:
Joseph Doherty
2026-07-15 17:37:02 -04:00
parent 698703744f
commit bbbda99719
4 changed files with 225 additions and 3 deletions
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -18,6 +19,9 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
/// <summary>Node-count cap the capturing builder enforces before marking the tree truncated.</summary>
public const int NodeCap = 50_000;
/// <summary>Global cap on simultaneous captures (§4.2). Coalesced awaiters don't hold a slot.</summary>
public const int MaxConcurrentCaptures = 4;
/// <summary>Per-type declarative config patch guaranteeing browse mode at open time (§5.1).
/// A dictionary entry per config-gated driver — not code.</summary>
internal static readonly IReadOnlyDictionary<string, string> BrowsePatches =
@@ -33,6 +37,8 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
private readonly ILogger<DiscoveryDriverBrowser> _logger;
private readonly TimeSpan _openTimeout;
private readonly TimeSpan _shutdownTimeout;
private readonly ConcurrentDictionary<string, Lazy<Task<CapturedTree>>> _inflight = new();
private readonly SemaphoreSlim _captureSlots = new(MaxConcurrentCaptures, MaxConcurrentCaptures);
/// <summary>Production constructor: standard open + shutdown bounds.</summary>
/// <param name="driverFactory">Factory used to construct throwaway/capture driver instances.</param>
@@ -75,10 +81,41 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
/// <inheritdoc />
public async Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct)
{
var tree = await CaptureAsync(driverType, configJson, ct).ConfigureAwait(false);
var key = CaptureKey(driverType, configJson);
// Lazy: exactly one RunCaptureAsync per key even under GetOrAdd races.
var capture = _inflight.GetOrAdd(key,
_ => new Lazy<Task<CapturedTree>>(() => RunCaptureAsync(key, driverType, configJson)));
// The shared capture runs independent of any single caller (its own OpenTimeout bounds it);
// each caller — including a queued one — observes ITS OWN ct via WaitAsync.
var tree = await capture.Value.WaitAsync(ct).ConfigureAwait(false);
return new CapturedTreeBrowseSession(tree);
}
private async Task<CapturedTree> RunCaptureAsync(string key, string driverType, string configJson)
{
try
{
await _captureSlots.WaitAsync().ConfigureAwait(false); // FIFO-ish queue for a slot
try
{
return await CaptureAsync(driverType, configJson, CancellationToken.None).ConfigureAwait(false);
}
finally
{
_captureSlots.Release();
}
}
finally
{
_inflight.TryRemove(key, out _); // in-flight only — no result caching (Refresh = fresh capture)
}
}
internal static string CaptureKey(string driverType, string configJson) =>
driverType.ToLowerInvariant() + ":" +
Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(configJson ?? "")));
private async Task<CapturedTree> CaptureAsync(string driverType, string configJson, CancellationToken ct)
{
var patched = PatchForBrowse(driverType, configJson);