refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.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>
|
||||
/// Thread-safety: backed by <see cref="ConcurrentDictionary{TKey,TValue}"/> of
|
||||
/// <see cref="Guid"/> to <see cref="BundleSession"/>. All store operations
|
||||
/// (<see cref="Get"/> / <see cref="Open"/> / <see cref="Remove"/> /
|
||||
/// <see cref="EvictExpired"/>) use the concurrent dictionary's safe primitives
|
||||
/// (<c>TryGetValue</c>, indexer assignment, <c>TryRemove</c>) and are safe
|
||||
/// under concurrent callers. The <see cref="BundleSession"/> instance itself
|
||||
/// is NOT thread-safe — callers that share a session reference (e.g. two
|
||||
/// importers mutating <c>FailedUnlockAttempts</c> on the same session) MUST
|
||||
/// serialize their mutations on that shared reference.
|
||||
/// </para>
|
||||
/// <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();
|
||||
|
||||
/// <summary>
|
||||
/// T-003: per-bundle unlock-failure counters, keyed by <see cref="BundleManifest.ContentHash"/>
|
||||
/// (SHA-256 hex of the bundle's content bytes). Failures are tracked here — not on
|
||||
/// <see cref="BundleSession"/> — so retries against the same bundle bytes from a
|
||||
/// second tab / CLI caller share the counter and cannot side-step the lockout. Entries
|
||||
/// expire on the same TTL as a session.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, UnlockFailureRecord> _unlockFailures = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptions<TransportOptions> _options;
|
||||
|
||||
/// <summary>T-003: per-bundle unlock-failure entry with expiry.</summary>
|
||||
private sealed class UnlockFailureRecord
|
||||
{
|
||||
public int Count;
|
||||
public DateTimeOffset ExpiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="BundleSessionStore"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">Transport options. <see cref="TransportOptions.BundleSessionTtlMinutes"/> is also used as the TTL for the T-003 per-bundle unlock-failure tracker.</param>
|
||||
/// <param name="timeProvider">Time provider used to evaluate session expiry.</param>
|
||||
public BundleSessionStore(IOptions<TransportOptions> options, TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_ = options.Value;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Remove(Guid sessionId)
|
||||
{
|
||||
_sessions.TryRemove(sessionId, out _);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EvictExpired()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
foreach (var kv in _sessions)
|
||||
{
|
||||
if (kv.Value.ExpiresAt <= now)
|
||||
{
|
||||
_sessions.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// T-003: also expire stale per-bundle unlock-failure entries so a bundle
|
||||
// that was previously locked clears once the lockout window passes.
|
||||
foreach (var kv in _unlockFailures)
|
||||
{
|
||||
if (kv.Value.ExpiresAt <= now)
|
||||
{
|
||||
_unlockFailures.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetUnlockFailureCount(string bundleContentHash)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
|
||||
if (!_unlockFailures.TryGetValue(bundleContentHash, out var record))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Lazy expiry — if the entry has aged past its window treat it as cleared.
|
||||
if (record.ExpiresAt <= _timeProvider.GetUtcNow())
|
||||
{
|
||||
_unlockFailures.TryRemove(bundleContentHash, out _);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return record.Count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int IncrementUnlockFailureCount(string bundleContentHash)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
|
||||
var ttl = TimeSpan.FromMinutes(_options.Value.BundleSessionTtlMinutes);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var record = _unlockFailures.AddOrUpdate(
|
||||
bundleContentHash,
|
||||
_ => new UnlockFailureRecord { Count = 1, ExpiresAt = now + ttl },
|
||||
(_, existing) =>
|
||||
{
|
||||
// Treat an expired record as a fresh start so a legitimate operator
|
||||
// returning hours later does not face a stale lockout.
|
||||
if (existing.ExpiresAt <= now)
|
||||
{
|
||||
existing.Count = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Count++;
|
||||
}
|
||||
|
||||
existing.ExpiresAt = now + ttl;
|
||||
return existing;
|
||||
});
|
||||
|
||||
return record.Count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearUnlockFailures(string bundleContentHash)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
|
||||
_unlockFailures.TryRemove(bundleContentHash, out _);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user