namespace Mbproxy.Proxy.Cache;
///
/// Per-PLC opt-in response cache for FC03 / FC04 read responses.
///
/// Lifecycle. One instance per PLC, owned by the per-PLC context. The cache
/// is consulted on every FC03/FC04 request before coalescing; populated by the backend
/// reader task AFTER the BCD rewriter has decoded the response; invalidated on every
/// successful FC06/FC16 write response that overlaps a cached read range.
///
/// Concurrency. A single lock serialises every method.
/// A per-PLC cache sees at most one outstanding FC03/FC04 read on the backend at any
/// instant (the multiplexer serialises onto the shared socket), but the read-on-hit path
/// is called from many upstream task contexts concurrently; the lock is small and fast.
///
/// LRU eviction. Each touch (hit or insert) assigns the entry the next value
/// from . When the cache reaches and a
/// new entry is inserted, the entry with the smallest
/// is removed.
///
/// TTL expiry. Entries past their are
/// dropped lazily on every read attempt, and also swept proactively by a background
/// loop every . The background
/// loop is the safety net that prevents abandoned entries (PLC whose clients all dropped)
/// from holding memory until process exit.
///
internal sealed class ResponseCache : IDisposable
{
// ── State ────────────────────────────────────────────────────────────────────
private readonly object _lock = new();
private readonly Dictionary _entries;
private readonly int _maxEntries;
private readonly int _evictionIntervalMs;
private long _lruTicker;
private long _approxBytes;
private readonly CancellationTokenSource _cts = new();
private readonly Task _evictionTask;
private bool _disposed;
///
/// Constructs a cache with the supplied capacity and eviction tick interval. The
/// eviction loop starts immediately; the cache becomes usable as soon as the
/// constructor returns.
///
/// LRU cap. Past this count, the next insert evicts
/// the least-recently-used entry. Must be >= 0; 0 disables caching entirely (every
/// call no-ops).
/// Background sweep interval in milliseconds. Clamped
/// to a 100 ms floor and an effective ceiling of int.MaxValue.
public ResponseCache(int maxEntriesPerPlc, int evictionIntervalMs)
{
if (maxEntriesPerPlc < 0)
throw new ArgumentOutOfRangeException(nameof(maxEntriesPerPlc),
"maxEntriesPerPlc must be >= 0.");
if (evictionIntervalMs < 0)
throw new ArgumentOutOfRangeException(nameof(evictionIntervalMs),
"evictionIntervalMs must be >= 0.");
_maxEntries = maxEntriesPerPlc;
// 100 ms floor — protects against pathologically tight loops; 0 (operator-pinned)
// becomes 100 ms here so the eviction task isn't a tight loop spinning on
// _entries.
_evictionIntervalMs = Math.Max(100, evictionIntervalMs);
_entries = new Dictionary(capacity: Math.Min(_maxEntries, 64));
_evictionTask = Task.Run(() => RunEvictionLoopAsync(_cts.Token));
}
/// Current entry count. Stable read under lock.
public int Count
{
get { lock (_lock) return _entries.Count; }
}
/// Approximation of cached PDU bytes (Sum of ). Stable read under lock.
public long ApproximateBytes
{
get { lock (_lock) return _approxBytes; }
}
///
/// Returns true with the cached when a non-expired
/// entry is present for . Expired entries are removed lazily.
/// Updates LRU ordering on hit.
///
public bool TryGet(CacheKey key, out CacheEntry entry)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
lock (_lock)
{
if (!_entries.TryGetValue(key, out var existing))
{
entry = null!;
return false;
}
if (existing.ExpiresAtUtc <= now)
{
// Expired — remove and miss.
_entries.Remove(key);
_approxBytes -= existing.Length;
entry = null!;
return false;
}
long tick = ++_lruTicker;
var refreshed = existing with { LastUsedTick = tick };
_entries[key] = refreshed;
entry = refreshed;
return true;
}
}
///
/// Inserts or replaces the entry under . If the cache is at
/// capacity, evicts the LRU entry first. No-op when is 0.
///
public void Set(CacheKey key, CacheEntry entry)
{
if (_maxEntries == 0) return;
lock (_lock)
{
long tick = ++_lruTicker;
var stamped = entry with { LastUsedTick = tick };
if (_entries.TryGetValue(key, out var existing))
{
// Replace; adjust byte accounting.
_approxBytes -= existing.Length;
_approxBytes += stamped.Length;
_entries[key] = stamped;
return;
}
// Insert. Evict LRU if at cap.
if (_entries.Count >= _maxEntries)
EvictLeastRecentlyUsed();
_entries[key] = stamped;
_approxBytes += stamped.Length;
}
}
///
/// Invalidates every entry whose range overlaps the write
/// [startAddress, startAddress + qty) on . Returns the
/// count of invalidated entries.
///
public int Invalidate(byte unitId, ushort startAddress, ushort qty)
{
lock (_lock)
{
// Snapshot keys for the pure overlap matcher.
var keys = _entries.Keys.ToArray();
int count = 0;
foreach (var k in CacheInvalidator.FindOverlapping(keys, unitId, startAddress, qty))
{
if (_entries.TryGetValue(k, out var existing))
{
_entries.Remove(k);
_approxBytes -= existing.Length;
count++;
}
}
return count;
}
}
///
/// Drops every entry. Used by hot-reload (per-PLC flush on tag-map change).
/// Returns the count of entries that were present before the flush.
///
public int Clear()
{
lock (_lock)
{
int n = _entries.Count;
_entries.Clear();
_approxBytes = 0;
return n;
}
}
///
/// Stops the eviction loop and disposes the internal CTS. Idempotent.
///
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try { _cts.Cancel(); } catch { /* best effort */ }
// Best-effort join the eviction loop; the loop will observe the cancellation and
// exit. We bound the wait so a faulted loop doesn't hold up disposal.
try { _evictionTask.Wait(TimeSpan.FromSeconds(1)); } catch { /* best effort */ }
_cts.Dispose();
}
// ── Eviction internals ───────────────────────────────────────────────────────
private void EvictLeastRecentlyUsed()
{
// Linear scan — acceptable at MaxEntriesPerPlc = 1000 (insert path is far cheaper
// than the network round-trip the cache is saving). A sorted secondary structure
// would be a premature optimisation.
CacheKey lruKey = default;
long lruTick = long.MaxValue;
bool found = false;
foreach (var kvp in _entries)
{
if (kvp.Value.LastUsedTick < lruTick)
{
lruTick = kvp.Value.LastUsedTick;
lruKey = kvp.Key;
found = true;
}
}
if (found && _entries.TryGetValue(lruKey, out var existing))
{
_entries.Remove(lruKey);
_approxBytes -= existing.Length;
}
}
private async Task RunEvictionLoopAsync(CancellationToken ct)
{
var period = TimeSpan.FromMilliseconds(_evictionIntervalMs);
using var timer = new PeriodicTimer(period);
try
{
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
{
SweepExpired();
}
}
catch (OperationCanceledException)
{
// Normal disposal.
}
catch
{
// Defensive — eviction loop must never fault the host. A swallow here means
// entries are only evicted on access until disposal, which is correctness-preserving.
}
}
private void SweepExpired()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
lock (_lock)
{
if (_entries.Count == 0) return;
// Two-pass to avoid mutating during enumeration.
var expired = new List();
foreach (var kvp in _entries)
{
if (kvp.Value.ExpiresAtUtc <= now)
expired.Add(kvp.Key);
}
foreach (var k in expired)
{
if (_entries.TryGetValue(k, out var existing))
{
_entries.Remove(k);
_approxBytes -= existing.Length;
}
}
}
}
}