feat(transport): in-memory BundleSessionStore with TTL + lockout

This commit is contained in:
Joseph Doherty
2026-05-24 04:20:55 -04:00
parent 06c2b20178
commit 901d9affdf
3 changed files with 215 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Commons.Types.Transport;
namespace ScadaLink.Transport.Import;
/// <summary>
/// In-memory implementation of <see cref="IBundleSessionStore"/> backed by a
/// <see cref="ConcurrentDictionary{TKey,TValue}"/>. Sessions are evicted lazily
/// at read time (<see cref="Get"/>) and on-demand via <see cref="EvictExpired"/>;
/// there is no background timer.
/// <para>
/// TTL is supplied by the importer via <see cref="BundleSession.ExpiresAt"/>;
/// this store does not impose its own. The injected <see cref="TimeProvider"/>
/// is used purely to determine <c>now</c> when checking <c>ExpiresAt</c>, which
/// keeps unit tests deterministic.
/// </para>
/// <para>
/// The 3-strike unlock lockout is owned by <see cref="BundleSession"/>
/// (<c>FailedUnlockAttempts</c> / <c>Locked</c>); the store just hands out the
/// shared session reference so the importer can mutate the counter in place.
/// </para>
/// </summary>
public sealed class BundleSessionStore : IBundleSessionStore
{
private readonly ConcurrentDictionary<Guid, BundleSession> _sessions = new();
private readonly TimeProvider _timeProvider;
// Options are accepted to honor the documented constructor contract and to
// be ready for future per-store knobs (e.g. max in-flight sessions). The
// current store does not read any field — TTL is on the session itself.
public BundleSessionStore(IOptions<TransportOptions> options, TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(options);
_ = options.Value;
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public BundleSession Open(BundleSession session)
{
ArgumentNullException.ThrowIfNull(session);
// Overwrite on collision is defensive: GUIDs are random so practical
// collisions don't happen, but if a caller reuses an id we always
// honor the latest Open call.
_sessions[session.SessionId] = session;
return session;
}
public BundleSession? Get(Guid sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session)) return null;
if (session.ExpiresAt > _timeProvider.GetUtcNow()) return session;
_sessions.TryRemove(sessionId, out _);
return null;
}
public void Remove(Guid sessionId)
{
_sessions.TryRemove(sessionId, out _);
}
public void EvictExpired()
{
var now = _timeProvider.GetUtcNow();
foreach (var kv in _sessions)
{
if (kv.Value.ExpiresAt <= now)
{
_sessions.TryRemove(kv.Key, out _);
}
}
}
}

View File

@@ -1,6 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Transport.Export;
using ScadaLink.Transport.Import;
namespace ScadaLink.Transport;
@@ -12,7 +15,9 @@ public static class ServiceCollectionExtensions
{
ArgumentNullException.ThrowIfNull(services);
services.AddOptions<TransportOptions>().BindConfiguration(OptionsSection);
services.TryAddSingleton(TimeProvider.System);
services.AddScoped<DependencyResolver>();
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();
// Remaining concrete services added in later tasks.
return services;
}