feat(adminui): in-process browse session registry + TTL reaper + service

This commit is contained in:
Joseph Doherty
2026-05-28 15:36:19 -04:00
parent 7cd5cde315
commit bec2988309
4 changed files with 217 additions and 0 deletions
@@ -0,0 +1,67 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Background service that periodically evicts idle browse sessions from the
/// <see cref="BrowseSessionRegistry"/>. Each tick takes a snapshot of the registry,
/// removes any session that has been idle longer than <see cref="IdleTtl"/>, then
/// disposes the evicted instance OUTSIDE the dictionary — so concurrent expand
/// calls racing eviction fail cleanly via <see cref="BrowseSessionNotFoundException"/>.
/// </summary>
public sealed class BrowseSessionReaper(
BrowseSessionRegistry registry,
ILogger<BrowseSessionReaper> logger) : BackgroundService
{
/// <summary>How long a session may be untouched before it becomes eligible for eviction.</summary>
public static readonly TimeSpan IdleTtl = TimeSpan.FromMinutes(2);
/// <summary>How often the reaper checks the registry.</summary>
public static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(30);
/// <inheritdoc />
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);
}
/// <summary>Evicts every session whose <see cref="Commons.Browsing.IBrowseSession.LastUsedUtc"/>
/// is older than <see cref="IdleTtl"/>. Internal so tests can drive a tick directly.</summary>
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);
}
}
}
@@ -0,0 +1,30 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Singleton in-process directory of live <see cref="IBrowseSession"/> instances,
/// keyed by <see cref="IBrowseSession.Token"/>. Concurrency is provided by the
/// underlying <see cref="ConcurrentDictionary{TKey, TValue}"/> alone — there are no
/// additional locks; callers must dispose evicted sessions outside the dictionary.
/// </summary>
public sealed class BrowseSessionRegistry
{
private readonly ConcurrentDictionary<Guid, IBrowseSession> _sessions = new();
/// <summary>Adds (or replaces) a session in the registry keyed by its token.</summary>
public void Register(IBrowseSession session) => _sessions[session.Token] = session;
/// <summary>Looks up a session by token without removing it.</summary>
public bool TryGet(Guid token, out IBrowseSession session) =>
_sessions.TryGetValue(token, out session!);
/// <summary>Atomically removes a session from the registry, returning it for disposal.</summary>
public bool TryRemove(Guid token, out IBrowseSession session) =>
_sessions.TryRemove(token, out session!);
/// <summary>Returns a point-in-time snapshot of all currently registered sessions.</summary>
public IReadOnlyList<(Guid Token, IBrowseSession Session)> Snapshot() =>
_sessions.Select(kv => (kv.Key, kv.Value)).ToList();
}
@@ -0,0 +1,72 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Default <see cref="IBrowserSessionService"/> implementation. Indexes injected
/// <see cref="IDriverBrowser"/>s by <see cref="IDriverBrowser.DriverType"/>
/// (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.
/// </summary>
public sealed class BrowserSessionService(
IEnumerable<IDriverBrowser> browsers,
BrowseSessionRegistry registry,
ILogger<BrowserSessionService> logger) : IBrowserSessionService
{
/// <summary>Upper bound on a single root/expand/attributes call.</summary>
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
private readonly IReadOnlyDictionary<string, IDriverBrowser> _browsersByType =
browsers.ToDictionary(b => b.DriverType, StringComparer.OrdinalIgnoreCase);
/// <inheritdoc />
public async Task<BrowseOpenResult> 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);
}
}
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct) =>
InvokeAsync(token, ct, (s, c) => s.RootAsync(c));
/// <inheritdoc />
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync(token, ct, (s, c) => s.ExpandAsync(nodeId, c));
/// <inheritdoc />
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync<IReadOnlyList<AttributeInfo>>(token, ct, (s, c) => s.AttributesAsync(nodeId, c));
/// <inheritdoc />
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<T> InvokeAsync<T>(
Guid token, CancellationToken callerCt, Func<IBrowseSession, CancellationToken, Task<T>> 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);
}
}
@@ -0,0 +1,48 @@
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Outcome of <see cref="IBrowserSessionService.OpenAsync"/>. On success
/// <paramref name="Ok"/> is <see langword="true"/> and <paramref name="Token"/> is the
/// registry handle; on failure <paramref name="Message"/> carries a human-readable
/// diagnostic for the UI's error chip.
/// </summary>
/// <param name="Ok">True iff the browse session was opened and registered.</param>
/// <param name="Message">Failure diagnostic, or <see langword="null"/> on success.</param>
/// <param name="Token">Registry handle on success; <see cref="Guid.Empty"/> on failure.</param>
public sealed record BrowseOpenResult(bool Ok, string? Message, Guid Token);
/// <summary>
/// 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.
/// </summary>
public interface IBrowserSessionService
{
/// <summary>Opens a session against the named driver type using the given JSON config.
/// Never throws — all errors are surfaced via <see cref="BrowseOpenResult"/>.</summary>
Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct);
/// <summary>Returns the root nodes of an open session. Throws
/// <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct);
/// <summary>Returns the direct children of <paramref name="nodeId"/> in an open session.
/// Throws <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct);
/// <summary>Returns the attributes of <paramref name="nodeId"/> in an open session. Throws
/// <see cref="BrowseSessionNotFoundException"/> if the token is unknown.</summary>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct);
/// <summary>Removes the session from the registry and disposes it. No-op for unknown tokens.</summary>
Task CloseAsync(Guid token);
}
/// <summary>
/// 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.
/// </summary>
public sealed class BrowseSessionNotFoundException(Guid token)
: InvalidOperationException($"Browse session {token} not found (may have been reaped).");