feat(adminui): in-process browse session registry + TTL reaper + service
This commit is contained in:
@@ -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).");
|
||||
Reference in New Issue
Block a user