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;
}

View File

@@ -0,0 +1,136 @@
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.Transport;
using ScadaLink.Transport.Import;
namespace ScadaLink.Transport.Tests.Import;
public sealed class BundleSessionStoreTests
{
private static IOptions<TransportOptions> Options() => Microsoft.Extensions.Options.Options.Create(new TransportOptions());
private static BundleSession SessionExpiringAt(DateTimeOffset expiresAt) => new()
{
SessionId = Guid.NewGuid(),
Manifest = StubManifest(),
DecryptedContent = Array.Empty<byte>(),
ExpiresAt = expiresAt,
};
private static BundleManifest StubManifest() => new(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: DateTimeOffset.UnixEpoch,
SourceEnvironment: "test",
ExportedBy: "t",
ScadaLinkVersion: "1",
ContentHash: "0",
Encryption: null,
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0),
Contents: Array.Empty<ManifestContentEntry>());
[Fact]
public void Open_then_Get_returns_session()
{
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
var session = SessionExpiringAt(clock.GetUtcNow().AddMinutes(30));
sut.Open(session);
var fetched = sut.Get(session.SessionId);
Assert.NotNull(fetched);
Assert.Same(session, fetched);
}
[Fact]
public void Get_after_TTL_returns_null_and_evicts()
{
var start = DateTimeOffset.UtcNow;
var clock = new TestTimeProvider(start);
var sut = new BundleSessionStore(Options(), clock);
var session = SessionExpiringAt(start.AddMinutes(1));
sut.Open(session);
clock.Advance(TimeSpan.FromMinutes(2));
Assert.Null(sut.Get(session.SessionId));
// Lookup with no time advancement now returns null again — proves the
// entry was evicted, not merely filtered.
Assert.Null(sut.Get(session.SessionId));
}
[Fact]
public void Remove_evicts_session()
{
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
var session = SessionExpiringAt(clock.GetUtcNow().AddHours(1));
sut.Open(session);
sut.Remove(session.SessionId);
Assert.Null(sut.Get(session.SessionId));
}
[Fact]
public void EvictExpired_removes_all_past_ttl()
{
var start = DateTimeOffset.UtcNow;
var clock = new TestTimeProvider(start);
var sut = new BundleSessionStore(Options(), clock);
var keep = SessionExpiringAt(start.AddMinutes(30));
var dropA = SessionExpiringAt(start.AddSeconds(10));
var dropB = SessionExpiringAt(start.AddMinutes(1));
sut.Open(keep);
sut.Open(dropA);
sut.Open(dropB);
clock.Advance(TimeSpan.FromMinutes(2));
sut.EvictExpired();
Assert.NotNull(sut.Get(keep.SessionId));
Assert.Null(sut.Get(dropA.SessionId));
Assert.Null(sut.Get(dropB.SessionId));
}
[Fact]
public void Three_failed_unlock_attempts_locks_session()
{
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
var session = SessionExpiringAt(clock.GetUtcNow().AddMinutes(30));
sut.Open(session);
var live = sut.Get(session.SessionId);
Assert.NotNull(live);
live!.FailedUnlockAttempts++;
Assert.False(live.Locked);
live.FailedUnlockAttempts++;
Assert.False(live.Locked);
live.FailedUnlockAttempts++;
Assert.True(live.Locked);
// Mutations are observable via re-fetch — confirms the store hands out
// the shared reference (not a snapshot).
var refetch = sut.Get(session.SessionId);
Assert.NotNull(refetch);
Assert.True(refetch!.Locked);
}
/// <summary>
/// Minimal in-test <see cref="TimeProvider"/> with a mutable clock. Used in
/// place of <c>Microsoft.Extensions.TimeProvider.Testing</c> (not in the
/// central package list). Only <see cref="GetUtcNow"/> is overridden; the
/// store does not use timers, so the base implementations suffice elsewhere.
/// </summary>
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset start) { _now = start; }
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
}