feat(transport): in-memory BundleSessionStore with TTL + lockout
This commit is contained in:
74
src/ScadaLink.Transport/Import/BundleSessionStore.cs
Normal file
74
src/ScadaLink.Transport/Import/BundleSessionStore.cs
Normal 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 _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user