From bec29883093a2a56ef1a1bbdb20487aef409f356 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 28 May 2026 15:36:19 -0400 Subject: [PATCH] feat(adminui): in-process browse session registry + TTL reaper + service --- .../Browsing/BrowseSessionReaper.cs | 67 +++++++++++++++++ .../Browsing/BrowseSessionRegistry.cs | 30 ++++++++ .../Browsing/BrowserSessionService.cs | 72 +++++++++++++++++++ .../Browsing/IBrowserSessionService.cs | 48 +++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionReaper.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionRegistry.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionReaper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionReaper.cs new file mode 100644 index 00000000..cffe77a6 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionReaper.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; + +/// +/// Background service that periodically evicts idle browse sessions from the +/// . Each tick takes a snapshot of the registry, +/// removes any session that has been idle longer than , then +/// disposes the evicted instance OUTSIDE the dictionary — so concurrent expand +/// calls racing eviction fail cleanly via . +/// +public sealed class BrowseSessionReaper( + BrowseSessionRegistry registry, + ILogger logger) : BackgroundService +{ + /// How long a session may be untouched before it becomes eligible for eviction. + public static readonly TimeSpan IdleTtl = TimeSpan.FromMinutes(2); + + /// How often the reaper checks the registry. + public static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(30); + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TickInterval); + while (!stoppingToken.IsCancellationRequested && + await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + { + try { await ReapOnceAsync(stoppingToken).ConfigureAwait(false); } + catch (Exception ex) + { + logger.LogWarning(ex, "Browse-session reaper iteration failed; will retry next tick."); + } + } + await DrainAllAsync().ConfigureAwait(false); + } + + /// Evicts every session whose + /// is older than . Internal so tests can drive a tick directly. + internal async Task ReapOnceAsync(CancellationToken ct) + { + var now = DateTime.UtcNow; + foreach (var (token, session) in registry.Snapshot()) + { + if (now - session.LastUsedUtc < IdleTtl) continue; + if (!registry.TryRemove(token, out var taken)) continue; + try { await taken.DisposeAsync().ConfigureAwait(false); } + catch (Exception ex) + { + logger.LogDebug(ex, + "Best-effort dispose of idle-evicted browse session {Token} failed.", token); + } + logger.LogDebug("Browse session {Token} closed reason=idle-ttl", token); + } + } + + private async Task DrainAllAsync() + { + foreach (var (token, session) in registry.Snapshot()) + { + if (!registry.TryRemove(token, out var taken)) continue; + try { await taken.DisposeAsync().ConfigureAwait(false); } catch { } + logger.LogDebug("Browse session {Token} closed reason=shutdown", token); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionRegistry.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionRegistry.cs new file mode 100644 index 00000000..ca6feee9 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowseSessionRegistry.cs @@ -0,0 +1,30 @@ +using System.Collections.Concurrent; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; + +/// +/// Singleton in-process directory of live instances, +/// keyed by . Concurrency is provided by the +/// underlying alone — there are no +/// additional locks; callers must dispose evicted sessions outside the dictionary. +/// +public sealed class BrowseSessionRegistry +{ + private readonly ConcurrentDictionary _sessions = new(); + + /// Adds (or replaces) a session in the registry keyed by its token. + public void Register(IBrowseSession session) => _sessions[session.Token] = session; + + /// Looks up a session by token without removing it. + public bool TryGet(Guid token, out IBrowseSession session) => + _sessions.TryGetValue(token, out session!); + + /// Atomically removes a session from the registry, returning it for disposal. + public bool TryRemove(Guid token, out IBrowseSession session) => + _sessions.TryRemove(token, out session!); + + /// Returns a point-in-time snapshot of all currently registered sessions. + public IReadOnlyList<(Guid Token, IBrowseSession Session)> Snapshot() => + _sessions.Select(kv => (kv.Key, kv.Value)).ToList(); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs new file mode 100644 index 00000000..60ac40b4 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; + +/// +/// Default implementation. Indexes injected +/// s by +/// (case-insensitive) at construction, registers opened sessions, and wraps each +/// expand/attributes call in a 20-second linked CTS so a stuck driver cannot +/// stall the UI indefinitely. +/// +public sealed class BrowserSessionService( + IEnumerable browsers, + BrowseSessionRegistry registry, + ILogger logger) : IBrowserSessionService +{ + /// Upper bound on a single root/expand/attributes call. + public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20); + + private readonly IReadOnlyDictionary _browsersByType = + browsers.ToDictionary(b => b.DriverType, StringComparer.OrdinalIgnoreCase); + + /// + public async Task OpenAsync(string driverType, string configJson, CancellationToken ct) + { + if (!_browsersByType.TryGetValue(driverType, out var browser)) + return new(false, $"No browser registered for driver type '{driverType}'.", Guid.Empty); + try + { + var session = await browser.OpenAsync(configJson, ct).ConfigureAwait(false); + registry.Register(session); + return new(true, null, session.Token); + } + catch (Exception ex) + { + logger.LogInformation(ex, + "Browser open failed for driverType={DriverType}: {Message}", driverType, ex.Message); + return new(false, ex.Message, Guid.Empty); + } + } + + /// + public Task> RootAsync(Guid token, CancellationToken ct) => + InvokeAsync(token, ct, (s, c) => s.RootAsync(c)); + + /// + public Task> ExpandAsync(Guid token, string nodeId, CancellationToken ct) => + InvokeAsync(token, ct, (s, c) => s.ExpandAsync(nodeId, c)); + + /// + public Task> AttributesAsync(Guid token, string nodeId, CancellationToken ct) => + InvokeAsync>(token, ct, (s, c) => s.AttributesAsync(nodeId, c)); + + /// + public async Task CloseAsync(Guid token) + { + if (!registry.TryRemove(token, out var session)) return; + try { await session.DisposeAsync().ConfigureAwait(false); } catch { } + logger.LogDebug("Browse session {Token} closed reason=user-close", token); + } + + private async Task InvokeAsync( + Guid token, CancellationToken callerCt, Func> op) + { + if (!registry.TryGet(token, out var session)) + throw new BrowseSessionNotFoundException(token); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(callerCt); + cts.CancelAfter(PerCallTimeout); + return await op(session, cts.Token).ConfigureAwait(false); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs new file mode 100644 index 00000000..b7b171bd --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs @@ -0,0 +1,48 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Browsing; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing; + +/// +/// Outcome of . On success +/// is and is the +/// registry handle; on failure carries a human-readable +/// diagnostic for the UI's error chip. +/// +/// True iff the browse session was opened and registered. +/// Failure diagnostic, or on success. +/// Registry handle on success; on failure. +public sealed record BrowseOpenResult(bool Ok, string? Message, Guid Token); + +/// +/// Scoped Razor-page facade over the in-process browse-session machinery. Owns +/// driver-type dispatch on open and per-call timeout enforcement on expand/attributes. +/// +public interface IBrowserSessionService +{ + /// Opens a session against the named driver type using the given JSON config. + /// Never throws — all errors are surfaced via . + Task OpenAsync(string driverType, string configJson, CancellationToken ct); + + /// Returns the root nodes of an open session. Throws + /// if the token is unknown. + Task> RootAsync(Guid token, CancellationToken ct); + + /// Returns the direct children of in an open session. + /// Throws if the token is unknown. + Task> ExpandAsync(Guid token, string nodeId, CancellationToken ct); + + /// Returns the attributes of in an open session. Throws + /// if the token is unknown. + Task> AttributesAsync(Guid token, string nodeId, CancellationToken ct); + + /// Removes the session from the registry and disposes it. No-op for unknown tokens. + Task CloseAsync(Guid token); +} + +/// +/// Raised by the service layer when a caller references a token that is not +/// (or no longer) in the registry — typically because the reaper evicted it +/// between calls. +/// +public sealed class BrowseSessionNotFoundException(Guid token) + : InvalidOperationException($"Browse session {token} not found (may have been reaped).");