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