1a2856526a
Comments described the *history* of how the code arrived (phase numbers, wave IDs, review IDs, dated TODOs) instead of what it does today. That scaffolding rotted as the codebase evolved. Cleaned 60 source files + .gitignore; behaviour unchanged (387/387 tests still pass). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
278 lines
10 KiB
C#
278 lines
10 KiB
C#
namespace Mbproxy.Proxy.Cache;
|
|
|
|
/// <summary>
|
|
/// Per-PLC opt-in response cache for FC03 / FC04 read responses.
|
|
///
|
|
/// <para><b>Lifecycle.</b> 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.</para>
|
|
///
|
|
/// <para><b>Concurrency.</b> A single <see cref="object"/> 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.</para>
|
|
///
|
|
/// <para><b>LRU eviction.</b> Each touch (hit or insert) assigns the entry the next value
|
|
/// from <see cref="_lruTicker"/>. When the cache reaches <see cref="_maxEntries"/> and a
|
|
/// new entry is inserted, the entry with the smallest <see cref="CacheEntry.LastUsedTick"/>
|
|
/// is removed.</para>
|
|
///
|
|
/// <para><b>TTL expiry.</b> Entries past their <see cref="CacheEntry.ExpiresAtUtc"/> are
|
|
/// dropped lazily on every read attempt, and also swept proactively by a background
|
|
/// <see cref="PeriodicTimer"/> loop every <see cref="_evictionIntervalMs"/>. The background
|
|
/// loop is the safety net that prevents abandoned entries (PLC whose clients all dropped)
|
|
/// from holding memory until process exit.</para>
|
|
/// </summary>
|
|
internal sealed class ResponseCache : IDisposable
|
|
{
|
|
// ── State ────────────────────────────────────────────────────────────────────
|
|
private readonly object _lock = new();
|
|
private readonly Dictionary<CacheKey, CacheEntry> _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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="maxEntriesPerPlc">LRU cap. Past this count, the next insert evicts
|
|
/// the least-recently-used entry. Must be >= 0; 0 disables caching entirely (every
|
|
/// <see cref="Set"/> call no-ops).</param>
|
|
/// <param name="evictionIntervalMs">Background sweep interval in milliseconds. Clamped
|
|
/// to a 100 ms floor and an effective ceiling of <c>int.MaxValue</c>.</param>
|
|
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<CacheKey, CacheEntry>(capacity: Math.Min(_maxEntries, 64));
|
|
|
|
_evictionTask = Task.Run(() => RunEvictionLoopAsync(_cts.Token));
|
|
}
|
|
|
|
/// <summary>Current entry count. Stable read under lock.</summary>
|
|
public int Count
|
|
{
|
|
get { lock (_lock) return _entries.Count; }
|
|
}
|
|
|
|
/// <summary>Approximation of cached PDU bytes (Sum of <see cref="CacheEntry.Length"/>). Stable read under lock.</summary>
|
|
public long ApproximateBytes
|
|
{
|
|
get { lock (_lock) return _approxBytes; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns <c>true</c> with the cached <see cref="CacheEntry"/> when a non-expired
|
|
/// entry is present for <paramref name="key"/>. Expired entries are removed lazily.
|
|
/// Updates LRU ordering on hit.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inserts or replaces the entry under <paramref name="key"/>. If the cache is at
|
|
/// capacity, evicts the LRU entry first. No-op when <see cref="_maxEntries"/> is 0.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Invalidates every entry whose <see cref="CacheKey"/> range overlaps the write
|
|
/// <c>[startAddress, startAddress + qty)</c> on <paramref name="unitId"/>. Returns the
|
|
/// count of invalidated entries.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public int Clear()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
int n = _entries.Count;
|
|
_entries.Clear();
|
|
_approxBytes = 0;
|
|
return n;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops the eviction loop and disposes the internal CTS. Idempotent.
|
|
/// </summary>
|
|
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<CacheKey>();
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|