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:
@@ -11,9 +11,9 @@
|
|||||||
{"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "completed", "blockedBy": [6]},
|
{"id": 7, "subject": "Task 7: CapturedTreeBrowseSession", "status": "completed", "blockedBy": [6]},
|
||||||
{"id": 8, "subject": "Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path", "status": "completed", "blockedBy": [7]},
|
{"id": 8, "subject": "Task 8: IUniversalDriverBrowser + DiscoveryDriverBrowser core open path", "status": "completed", "blockedBy": [7]},
|
||||||
{"id": 9, "subject": "Task 9: UntilStable settle loop (FOCAS)", "status": "completed", "blockedBy": [8], "parallelWith": [11]},
|
{"id": 9, "subject": "Task 9: UntilStable settle loop (FOCAS)", "status": "completed", "blockedBy": [8], "parallelWith": [11]},
|
||||||
{"id": 10, "subject": "Task 10: Capture coalescing + MaxConcurrentCaptures cap", "status": "pending", "blockedBy": [9], "parallelWith": [12]},
|
{"id": 10, "subject": "Task 10: Capture coalescing + MaxConcurrentCaptures cap", "status": "completed", "blockedBy": [9], "parallelWith": [12]},
|
||||||
{"id": 11, "subject": "Task 11: BrowserSessionService universal fallback + CanBrowse", "status": "completed", "blockedBy": [8], "parallelWith": [9]},
|
{"id": 11, "subject": "Task 11: BrowserSessionService universal fallback + CanBrowse", "status": "completed", "blockedBy": [8], "parallelWith": [9]},
|
||||||
{"id": 12, "subject": "Task 12: DI registration in AddAdminUI", "status": "pending", "blockedBy": [11], "parallelWith": [10]},
|
{"id": 12, "subject": "Task 12: DI registration in AddAdminUI", "status": "completed", "blockedBy": [11], "parallelWith": [10]},
|
||||||
{"id": 13, "subject": "Task 13: TagModal -> editor parameter plumbing (DriverType + GetDriverConfigJson)", "status": "completed", "blockedBy": [0], "parallelWith": [1, 2]},
|
{"id": 13, "subject": "Task 13: TagModal -> editor parameter plumbing (DriverType + GetDriverConfigJson)", "status": "completed", "blockedBy": [0], "parallelWith": [1, 2]},
|
||||||
{"id": 14, "subject": "Task 14: AbCip picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 3], "parallelWith": [15, 16]},
|
{"id": 14, "subject": "Task 14: AbCip picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 3], "parallelWith": [15, 16]},
|
||||||
{"id": 15, "subject": "Task 15: TwinCAT picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 4], "parallelWith": [14, 16]},
|
{"id": 15, "subject": "Task 15: TwinCAT picker body universal browse mode", "status": "pending", "blockedBy": [10, 12, 13, 4], "parallelWith": [14, 16]},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
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>
|
/// <summary>Node-count cap the capturing builder enforces before marking the tree truncated.</summary>
|
||||||
public const int NodeCap = 50_000;
|
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).
|
/// <summary>Per-type declarative config patch guaranteeing browse mode at open time (§5.1).
|
||||||
/// A dictionary entry per config-gated driver — not code.</summary>
|
/// A dictionary entry per config-gated driver — not code.</summary>
|
||||||
internal static readonly IReadOnlyDictionary<string, string> BrowsePatches =
|
internal static readonly IReadOnlyDictionary<string, string> BrowsePatches =
|
||||||
@@ -33,6 +37,8 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
|
|||||||
private readonly ILogger<DiscoveryDriverBrowser> _logger;
|
private readonly ILogger<DiscoveryDriverBrowser> _logger;
|
||||||
private readonly TimeSpan _openTimeout;
|
private readonly TimeSpan _openTimeout;
|
||||||
private readonly TimeSpan _shutdownTimeout;
|
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>
|
/// <summary>Production constructor: standard open + shutdown bounds.</summary>
|
||||||
/// <param name="driverFactory">Factory used to construct throwaway/capture driver instances.</param>
|
/// <param name="driverFactory">Factory used to construct throwaway/capture driver instances.</param>
|
||||||
@@ -75,10 +81,41 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IBrowseSession> OpenAsync(string driverType, string configJson, CancellationToken ct)
|
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);
|
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)
|
private async Task<CapturedTree> CaptureAsync(string driverType, string configJson, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var patched = PatchForBrowse(driverType, configJson);
|
var patched = PatchForBrowse(driverType, configJson);
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ using Microsoft.AspNetCore.Components;
|
|||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
||||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
|
||||||
|
|
||||||
@@ -48,6 +50,12 @@ public static class EndpointRouteBuilderExtensions
|
|||||||
services.AddScoped<IUnsTreeService, UnsTreeService>();
|
services.AddScoped<IUnsTreeService, UnsTreeService>();
|
||||||
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
|
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
|
||||||
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
|
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
|
||||||
|
// Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
|
||||||
|
// fused Host's DriverFactoryBootstrap; a standalone AdminUI has none → NullDriverFactory
|
||||||
|
// → CanBrowse false → pickers gracefully stay manual-entry (design §6).
|
||||||
|
services.AddSingleton<IUniversalDriverBrowser>(sp => new DiscoveryDriverBrowser(
|
||||||
|
sp.GetService<IDriverFactory>() ?? NullDriverFactory.Instance,
|
||||||
|
sp.GetRequiredService<ILogger<DiscoveryDriverBrowser>>()));
|
||||||
|
|
||||||
// Roslyn-backed Monaco script-editor analysis (diagnostics/completions/hover/...).
|
// Roslyn-backed Monaco script-editor analysis (diagnostics/completions/hover/...).
|
||||||
services.AddScoped<ScriptAnalysis.ScriptAnalysisService>();
|
services.AddScoped<ScriptAnalysis.ScriptAnalysisService>();
|
||||||
|
|||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Shouldly;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
|
||||||
|
|
||||||
|
public class DiscoveryDriverBrowserConcurrencyTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan LongOpen = TimeSpan.FromSeconds(30);
|
||||||
|
private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(200);
|
||||||
|
private static readonly TimeSpan Wait = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
private static DiscoveryDriverBrowser Browser(IDriverFactory factory) =>
|
||||||
|
new(factory, NullLogger<DiscoveryDriverBrowser>.Instance, LongOpen, ShortShutdown);
|
||||||
|
|
||||||
|
/// <summary>A driver gate: signals when Initialize is entered, blocks until released.</summary>
|
||||||
|
private sealed class Gate
|
||||||
|
{
|
||||||
|
public readonly SemaphoreSlim Entered = new(0);
|
||||||
|
public readonly TaskCompletionSource Release = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FakeDiscoveryDriver Gated(Gate gate) => new()
|
||||||
|
{
|
||||||
|
OnInitialize = async ct =>
|
||||||
|
{
|
||||||
|
gate.Entered.Release();
|
||||||
|
await gate.Release.Task.WaitAsync(ct);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameKey_TwoConcurrentOpens_OneCapture_DistinctTokens()
|
||||||
|
{
|
||||||
|
var gate = new Gate();
|
||||||
|
var factory = FakeDriverFactory.Of(() => Gated(gate));
|
||||||
|
var browser = Browser(factory);
|
||||||
|
|
||||||
|
var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
|
||||||
|
await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken); // one driver entered Initialize
|
||||||
|
factory.CreateCalls.ShouldBe(1); // coalesced — only one driver constructed
|
||||||
|
gate.Release.SetResult();
|
||||||
|
|
||||||
|
var s1 = await open1;
|
||||||
|
var s2 = await open2;
|
||||||
|
s1.Token.ShouldNotBe(s2.Token); // distinct sessions...
|
||||||
|
(await s1.RootAsync(CancellationToken.None)).Count
|
||||||
|
.ShouldBe((await s2.RootAsync(CancellationToken.None)).Count); // ...over the same tree
|
||||||
|
((FakeDiscoveryDriver)factory.Created[0]).InitializeCalls.ShouldBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameKey_DisposingOneSession_LeavesOtherBrowsable()
|
||||||
|
{
|
||||||
|
var gate = new Gate();
|
||||||
|
var browser = Browser(FakeDriverFactory.Of(() => Gated(gate)));
|
||||||
|
|
||||||
|
var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken);
|
||||||
|
gate.Release.SetResult();
|
||||||
|
|
||||||
|
var s1 = await open1;
|
||||||
|
var s2 = await open2;
|
||||||
|
await s1.DisposeAsync();
|
||||||
|
(await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty(); // shared immutable tree survives
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameKey_FailedCapture_FailsBothAwaiters()
|
||||||
|
{
|
||||||
|
var factory = FakeDriverFactory.Of(() => new FakeDiscoveryDriver
|
||||||
|
{
|
||||||
|
OnDiscover = (_, _) => throw new InvalidOperationException("shared-boom"),
|
||||||
|
});
|
||||||
|
var browser = Browser(factory);
|
||||||
|
|
||||||
|
var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
|
||||||
|
(await Should.ThrowAsync<InvalidOperationException>(() => open1)).Message.ShouldBe("shared-boom");
|
||||||
|
(await Should.ThrowAsync<InvalidOperationException>(() => open2)).Message.ShouldBe("shared-boom");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameKey_OpenAfterCompletion_RunsFreshCapture()
|
||||||
|
{
|
||||||
|
var factory = FakeDriverFactory.Of(() => new FakeDiscoveryDriver()); // ungated, completes fast
|
||||||
|
var browser = Browser(factory);
|
||||||
|
|
||||||
|
await browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
await browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
|
||||||
|
factory.CreateCalls.ShouldBe(2); // in-flight-only map: second open is a fresh capture (Refresh path)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DistinctKeys_CapLimitsInflightCaptures()
|
||||||
|
{
|
||||||
|
const int cap = DiscoveryDriverBrowser.MaxConcurrentCaptures; // 4
|
||||||
|
const int total = cap + 2; // 6
|
||||||
|
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var entered = 0;
|
||||||
|
var factory = new FakeDriverFactory((_, _, _) => new FakeDiscoveryDriver
|
||||||
|
{
|
||||||
|
OnInitialize = async ct => { Interlocked.Increment(ref entered); await release.Task.WaitAsync(ct); },
|
||||||
|
});
|
||||||
|
var browser = Browser(factory);
|
||||||
|
|
||||||
|
var opens = Enumerable.Range(0, total)
|
||||||
|
.Select(i => browser.OpenAsync("Fake", $$"""{"i":{{i}}}""", CancellationToken.None))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Give the semaphore time to admit exactly `cap` captures into Initialize.
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(500), TestContext.Current.CancellationToken);
|
||||||
|
Volatile.Read(ref entered).ShouldBe(cap); // at most 4 in flight; the other 2 queue on the semaphore
|
||||||
|
|
||||||
|
release.SetResult();
|
||||||
|
var sessions = await Task.WhenAll(opens);
|
||||||
|
sessions.Length.ShouldBe(total);
|
||||||
|
factory.CreateCalls.ShouldBe(total); // all eventually run once a slot frees
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task QueuedOpen_ObservesCallerCancellation()
|
||||||
|
{
|
||||||
|
const int cap = DiscoveryDriverBrowser.MaxConcurrentCaptures; // 4
|
||||||
|
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var factory = new FakeDriverFactory((_, _, _) => new FakeDiscoveryDriver
|
||||||
|
{
|
||||||
|
OnInitialize = async ct => { await release.Task.WaitAsync(ct); },
|
||||||
|
});
|
||||||
|
var browser = Browser(factory);
|
||||||
|
|
||||||
|
// Fill all slots with distinct-key captures.
|
||||||
|
var inflight = Enumerable.Range(0, cap)
|
||||||
|
.Select(i => browser.OpenAsync("Fake", $$"""{"i":{{i}}}""", CancellationToken.None))
|
||||||
|
.ToArray();
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
// A queued open with a cancelable token — it waits for a slot, then the caller cancels.
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var queued = browser.OpenAsync("Fake", """{"i":"queued"}""", cts.Token);
|
||||||
|
cts.Cancel();
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(() => queued);
|
||||||
|
|
||||||
|
// The in-flight captures are unaffected.
|
||||||
|
release.SetResult();
|
||||||
|
var done = await Task.WhenAll(inflight);
|
||||||
|
done.Length.ShouldBe(cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CoalescedAwaiter_CancellationDoesNotCancelSharedCapture()
|
||||||
|
{
|
||||||
|
var gate = new Gate();
|
||||||
|
var factory = FakeDriverFactory.Of(() => Gated(gate));
|
||||||
|
var browser = Browser(factory);
|
||||||
|
|
||||||
|
using var cts1 = new CancellationTokenSource();
|
||||||
|
var open1 = browser.OpenAsync("Fake", """{"h":"1"}""", cts1.Token);
|
||||||
|
var open2 = browser.OpenAsync("Fake", """{"h":"1"}""", CancellationToken.None);
|
||||||
|
|
||||||
|
await gate.Entered.WaitAsync(Wait, TestContext.Current.CancellationToken);
|
||||||
|
cts1.Cancel(); // first caller abandons
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(() => open1);
|
||||||
|
|
||||||
|
gate.Release.SetResult(); // shared capture proceeds regardless
|
||||||
|
var s2 = await open2;
|
||||||
|
(await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty();
|
||||||
|
((FakeDiscoveryDriver)factory.Created[0]).InitializeCalls.ShouldBe(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user