mbproxy: add opt-in response cache (Phase 11)
Layers a per-PLC, per-tag response cache on top of Phase 10's coalescing.
Cache is OFF by default per tag (CacheTtlMs = 0); a fresh deployment with no
TTL config behaves identically to Phase 10. Operators opt tags in by setting
CacheTtlMs > 0 on a BcdTagOptions entry (or DefaultCacheTtlMs > 0 on a
PlcOptions entry), explicitly acknowledging the staleness window.
Cache lookup order: cache -> coalesce -> backend. A cache hit short-circuits
both Phase 10's coalescing path and Phase 9's backend send. Cache stores
POST-rewriter PDU bytes so hits never re-invoke the BCD rewriter. FC06/FC16
write responses invalidate every cached entry whose address range overlaps
the write (half-open interval math).
New types (Mbproxy.Proxy.Cache, all internal):
- CacheKey (record-struct, same shape as CoalescingKey but kept SEPARATE so
the two phases evolve independently).
- CacheEntry, ResponseCache (IDisposable; LRU + PeriodicTimer eviction
loop), CacheInvalidator (pure overlap matcher), CacheLogEvents (stable
mbproxy.cache.* names).
Multi-tag range TTL = min(TTLs); any tag with TTL = 0 in the range disables
caching for the whole read (conservative-by-design).
Options surface:
- BcdTagOptions.CacheTtlMs (nullable int; null = fall through to PLC default)
- PlcOptions.DefaultCacheTtlMs
- MbproxyOptions.Cache.{AllowLongTtl, MaxEntriesPerPlc, EvictionIntervalMs}
- TTL > 60_000 ms requires Cache.AllowLongTtl = true (reload validation).
Admin counters (Tier 1.8 + Tier 2 cache-memory KPIs from docs/kpi.md):
- CacheHitCount, CacheMissCount, CacheInvalidations on ProxyCounters.
- CacheEntryCount, CacheBytes via a new ICacheStatsProvider snapshot path.
- /status.json and the HTML page surface a new Cache cell per PLC row.
Hot-reload: any tag-list change to a PLC reseats the per-PLC context with a
fresh cache; the old cache is disposed inside ReplaceContextAsync. Per-tag
flush granularity is intentionally not implemented in v1.
PLCs with no cache-eligible tags (every resolved tag has CacheTtlMs = 0)
get Cache = null on the context and skip the eviction timer entirely, so
the no-cache path is byte-identical to Phase 10.
Tests (32 new unit + 5 new E2E = 37 new; suite now 314 unit + 48 E2E):
- CacheKeyTests, CacheEntryTests (records + boundary semantics).
- CacheInvalidatorTests: full overlap, both partials, adjacent-not-
overlapping, disjoint, different unit ID + auxiliary FC-filter / zero-qty.
- ResponseCacheTests: round-trip, lazy expiry, range invalidation,
unit-id filter, LRU bound, LRU access tracking, concurrent get/set,
dispose, clear, approximate-bytes accounting.
- ResponseCacheMultiplexerTests (stub-backend): hit short-circuits
coalescing, BCD-decoded bytes are cached not raw, FC06 invalidates
overlapping, non-overlapping write does not invalidate, multi-tag
TTL=min rule, regression-cache-disabled-by-default-is-Phase-10, hit
works even when backend unreachable.
- ResponseCacheE2ETests (pymodbus DL205 sim, sequential reads):
* Headline: 10 reads with TTL=1000 ms -> 9 hits, 1 miss, 1 backend trip.
* TTL expiry path with sleep > TTL.
* Write invalidation through the proxy on a scratch register.
* BCD-decoded bytes are cached, not raw BCD nibbles.
* Regression: Cache disabled by default -> behaviour byte-identical to
Phase 10.
Pre-existing flake hardened: BackendDisconnect_CascadesToAllUpstreams now
polls briefly for the cascade counter to absorb the inherent scheduling
gap between "upstream EOF observed" and "counter incremented inside
TearDownBackendAsync." Counter semantics unchanged.
Phase doc updated with implementation clarifications discovered during
this work (CacheKey kept separate from CoalescingKey, LastUsedTick is
long, FC06/FC16 startAddr/qty parsing extension, cache-pre-connect
short-circuit, write-invalidation only on successful responses).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,7 +74,10 @@ public sealed record FcCounts(
|
||||
/// <c>QueueDepth</c>. Phase 10 added the three coalescing counters
|
||||
/// (<c>CoalescedHitCount</c>, <c>CoalescedMissCount</c>, <c>CoalescedResponseToDeadUpstream</c>);
|
||||
/// the dashboard-side derived <c>coalescingRatio</c> is intentionally NOT carried on the wire
|
||||
/// — consumers compute <c>Hit / (Hit + Miss)</c>.
|
||||
/// — consumers compute <c>Hit / (Hit + Miss)</c>. Phase 11 added the five cache counters
|
||||
/// (<c>CacheHitCount</c>, <c>CacheMissCount</c>, <c>CacheInvalidations</c>,
|
||||
/// <c>CacheEntryCount</c>, <c>CacheBytes</c>); the dashboard-side derived
|
||||
/// <c>cacheHitRatio</c> is intentionally NOT carried on the wire.
|
||||
/// </summary>
|
||||
public sealed record PlcBackendStatus(
|
||||
long ConnectsSuccess,
|
||||
@@ -88,7 +91,12 @@ public sealed record PlcBackendStatus(
|
||||
long QueueDepth,
|
||||
long CoalescedHitCount,
|
||||
long CoalescedMissCount,
|
||||
long CoalescedResponseToDeadUpstream);
|
||||
long CoalescedResponseToDeadUpstream,
|
||||
long CacheHitCount,
|
||||
long CacheMissCount,
|
||||
long CacheInvalidations,
|
||||
long CacheEntryCount,
|
||||
long CacheBytes);
|
||||
|
||||
/// <summary>Modbus exception counts by code.</summary>
|
||||
public sealed record ExceptionCounts(
|
||||
|
||||
@@ -84,6 +84,10 @@ internal static class StatusHtmlRenderer
|
||||
// a percentage plus the raw hit count for context. Kept compact (one cell) to
|
||||
// stay under the 50 KB page-weight budget.
|
||||
sb.Append("<th>Coal</th>");
|
||||
// Phase 11: cache column. Single cell carries hit-ratio percent plus raw hit
|
||||
// count; an em-dash when no cache-eligible reads have occurred. Page-weight
|
||||
// budget assertion stays under 50 KB for the 54-PLC fleet.
|
||||
sb.Append("<th>Cache</th>");
|
||||
sb.Append("</tr></thead><tbody>");
|
||||
|
||||
foreach (var plc in status.Plcs)
|
||||
@@ -165,6 +169,20 @@ internal static class StatusHtmlRenderer
|
||||
sb.Append(pct).Append("% (").Append(coalHit).Append(')');
|
||||
}
|
||||
sb.Append("</td>");
|
||||
// Phase 11: cache ratio cell — same pattern as coalescing.
|
||||
long cacheHit = plc.Backend.CacheHitCount;
|
||||
long cacheMiss = plc.Backend.CacheMissCount;
|
||||
sb.Append("<td>");
|
||||
if (cacheHit + cacheMiss == 0)
|
||||
{
|
||||
sb.Append("—");
|
||||
}
|
||||
else
|
||||
{
|
||||
int pct = (int)Math.Round(100.0 * cacheHit / (cacheHit + cacheMiss));
|
||||
sb.Append(pct).Append("% (").Append(cacheHit).Append(')');
|
||||
}
|
||||
sb.Append("</td>");
|
||||
sb.Append("</tr>");
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,12 @@ internal sealed class StatusSnapshotBuilder
|
||||
BackendQueueDepth: 0,
|
||||
CoalescedHitCount: 0,
|
||||
CoalescedMissCount: 0,
|
||||
CoalescedResponseToDeadUpstream: 0);
|
||||
CoalescedResponseToDeadUpstream: 0,
|
||||
CacheHitCount: 0,
|
||||
CacheMissCount: 0,
|
||||
CacheInvalidations: 0,
|
||||
CacheEntryCount: 0,
|
||||
CacheBytes: 0);
|
||||
|
||||
// Phase 08: ConnectsSuccess / ConnectsFailed are now tracked in ProxyCounters.
|
||||
long connectsSuccess = counters.ConnectsSuccess;
|
||||
@@ -140,7 +145,12 @@ internal sealed class StatusSnapshotBuilder
|
||||
QueueDepth: counters.BackendQueueDepth,
|
||||
CoalescedHitCount: counters.CoalescedHitCount,
|
||||
CoalescedMissCount: counters.CoalescedMissCount,
|
||||
CoalescedResponseToDeadUpstream: counters.CoalescedResponseToDeadUpstream),
|
||||
CoalescedResponseToDeadUpstream: counters.CoalescedResponseToDeadUpstream,
|
||||
CacheHitCount: counters.CacheHitCount,
|
||||
CacheMissCount: counters.CacheMissCount,
|
||||
CacheInvalidations: counters.CacheInvalidations,
|
||||
CacheEntryCount: counters.CacheEntryCount,
|
||||
CacheBytes: counters.CacheBytes),
|
||||
Bytes: new PlcBytesStatus(
|
||||
UpstreamIn: counters.BytesUpstreamIn,
|
||||
UpstreamOut: counters.BytesUpstreamOut)));
|
||||
|
||||
@@ -3,26 +3,39 @@ namespace Mbproxy.Bcd;
|
||||
/// <summary>
|
||||
/// Immutable description of a single BCD-encoded V-memory tag as seen on the Modbus wire.
|
||||
/// Width is 16 (one register) or 32 (two registers, CDAB low-word-first).
|
||||
///
|
||||
/// <para><b>Phase 11 — <see cref="CacheTtlMs"/></b> is the resolved per-tag response-cache
|
||||
/// TTL in milliseconds. 0 (the default) means caching is disabled for this tag. Positive
|
||||
/// values cap upstream staleness; the multi-tag-range read uses <c>min(TTLs)</c> across all
|
||||
/// matched tags and treats any 0 in the range as "uncached for the whole read."</para>
|
||||
/// </summary>
|
||||
public sealed record BcdTag(ushort Address, byte Width)
|
||||
public sealed record BcdTag(ushort Address, byte Width, int CacheTtlMs = 0)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="BcdTag"/> and validates that Width is 16 or 32.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentException">Width is not 16 or 32.</exception>
|
||||
public static BcdTag Create(ushort address, byte width)
|
||||
public static BcdTag Create(ushort address, byte width, int cacheTtlMs = 0)
|
||||
{
|
||||
if (width != 16 && width != 32)
|
||||
throw new ArgumentException(
|
||||
$"BCD tag Width must be 16 or 32; got {width} at address {address}.",
|
||||
nameof(width));
|
||||
|
||||
return new BcdTag(address, width);
|
||||
if (cacheTtlMs < 0)
|
||||
throw new ArgumentException(
|
||||
$"BCD tag CacheTtlMs must be >= 0; got {cacheTtlMs} at address {address}.",
|
||||
nameof(cacheTtlMs));
|
||||
|
||||
return new BcdTag(address, width, cacheTtlMs);
|
||||
}
|
||||
|
||||
/// <summary>True when this tag occupies two registers (32-bit BCD).</summary>
|
||||
public bool IsThirtyTwoBit => Width == 32;
|
||||
|
||||
/// <summary>True when this tag opts into the Phase-11 response cache.</summary>
|
||||
public bool IsCacheable => CacheTtlMs > 0;
|
||||
|
||||
/// <summary>
|
||||
/// The address of the high-word register for a 32-bit tag (Address + 1).
|
||||
/// Only valid when <see cref="IsThirtyTwoBit"/> is true.
|
||||
|
||||
@@ -46,6 +46,37 @@ public sealed class BcdTagMap
|
||||
public bool TryGet(ushort address, out BcdTag tag)
|
||||
=> _map.TryGetValue(address, out tag!);
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — resolves the effective cache TTL for an FC03/FC04 read over the range
|
||||
/// [<paramref name="startAddress"/>, <paramref name="startAddress"/> + <paramref name="qty"/>).
|
||||
///
|
||||
/// <para>Returns 0 (uncached) when:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>The range covers no configured BCD tags (nothing to cache for, conservatively).</description></item>
|
||||
/// <item><description>Any covered tag has <see cref="BcdTag.CacheTtlMs"/> = 0 (the
|
||||
/// conservative-by-design "if any tag is uncached, the whole read is uncached" rule).</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Otherwise returns the minimum non-zero TTL across all covered tags.</para>
|
||||
///
|
||||
/// <para>Allocation-free in every path (delegates to <see cref="TryGetForRange"/> which
|
||||
/// is allocation-free on no-hit and allocates only the hit list on hit).</para>
|
||||
/// </summary>
|
||||
public int ResolveCacheTtlMs(ushort startAddress, ushort qty)
|
||||
{
|
||||
if (!TryGetForRange(startAddress, qty, out var hits) || hits.Count == 0)
|
||||
return 0;
|
||||
|
||||
int min = int.MaxValue;
|
||||
foreach (var hit in hits)
|
||||
{
|
||||
int ttl = hit.Tag.CacheTtlMs;
|
||||
if (ttl <= 0) return 0;
|
||||
if (ttl < min) min = ttl;
|
||||
}
|
||||
return min == int.MaxValue ? 0 : min;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns every BCD tag whose register footprint intersects
|
||||
/// [<paramref name="startAddress"/>, <paramref name="startAddress"/> + <paramref name="qty"/>).
|
||||
|
||||
@@ -31,6 +31,24 @@ public static class BcdTagMapBuilder
|
||||
/// <see cref="ValidationResult.Errors"/> as a fatal configuration problem.
|
||||
/// </returns>
|
||||
public static ValidationResult Build(BcdTagListOptions global, PlcBcdOverrides? perPlc)
|
||||
=> Build(global, perPlc, perPlcDefaultCacheTtlMs: 0);
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 overload — resolves the effective BCD tag list for one PLC and validates
|
||||
/// it, additionally folding the per-PLC <paramref name="perPlcDefaultCacheTtlMs"/> into
|
||||
/// any tag whose explicit <see cref="BcdTagOptions.CacheTtlMs"/> is null.
|
||||
///
|
||||
/// <para>Resolution order per tag:</para>
|
||||
/// <list type="number">
|
||||
/// <item><description>Explicit per-tag <c>CacheTtlMs</c> if set (including explicit 0).</description></item>
|
||||
/// <item><description>Otherwise the per-PLC default.</description></item>
|
||||
/// <item><description>Otherwise 0 (uncached).</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static ValidationResult Build(
|
||||
BcdTagListOptions global,
|
||||
PlcBcdOverrides? perPlc,
|
||||
int perPlcDefaultCacheTtlMs)
|
||||
{
|
||||
var errors = new List<BcdError>();
|
||||
var warnings = new List<BcdWarning>();
|
||||
@@ -84,7 +102,12 @@ public static class BcdTagMapBuilder
|
||||
continue;
|
||||
}
|
||||
|
||||
validated[addr] = BcdTag.Create(addr, opt.Width);
|
||||
// Phase 11 — resolve the effective per-tag cache TTL:
|
||||
// explicit per-tag (including 0) wins; otherwise fall back to per-PLC default.
|
||||
int resolvedTtl = opt.CacheTtlMs ?? perPlcDefaultCacheTtlMs;
|
||||
if (resolvedTtl < 0) resolvedTtl = 0;
|
||||
|
||||
validated[addr] = BcdTag.Create(addr, opt.Width, resolvedTtl);
|
||||
}
|
||||
|
||||
// High-register collision check (only meaningful for 32-bit entries).
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Threading.Channels;
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Cache;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Mbproxy.Proxy.Supervision;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -274,14 +275,15 @@ internal sealed partial class ConfigReconciler : IDisposable
|
||||
await old.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Build fresh context.
|
||||
var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags);
|
||||
// Build fresh context. Phase 11: pass DefaultCacheTtlMs.
|
||||
var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs);
|
||||
var newCtx = new PerPlcContext
|
||||
{
|
||||
PlcName = plcNew.Name,
|
||||
TagMap = result.Map,
|
||||
Counters = new Proxy.ProxyCounters(),
|
||||
Logger = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{plcNew.Name}"),
|
||||
Cache = BuildCacheIfNeeded(result.Map, next.Cache),
|
||||
};
|
||||
|
||||
// Build and start new supervisor.
|
||||
@@ -330,6 +332,9 @@ internal sealed partial class ConfigReconciler : IDisposable
|
||||
// Preserve existing counters so operators see real history.
|
||||
Counters = supervisor.CurrentCounters,
|
||||
Logger = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{name}"),
|
||||
// Phase 11: any reseat (tag-map change) constructs a fresh cache.
|
||||
// The supervisor disposes the old one inside ReplaceContextAsync.
|
||||
Cache = BuildCacheIfNeeded(newMap, next.Cache),
|
||||
};
|
||||
|
||||
using var reseatCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -355,13 +360,15 @@ internal sealed partial class ConfigReconciler : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags);
|
||||
// Phase 11: pass DefaultCacheTtlMs.
|
||||
var result = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs);
|
||||
var newCtx = new PerPlcContext
|
||||
{
|
||||
PlcName = plcNew.Name,
|
||||
TagMap = result.Map,
|
||||
Counters = new Proxy.ProxyCounters(),
|
||||
Logger = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{plcNew.Name}"),
|
||||
Cache = BuildCacheIfNeeded(result.Map, next.Cache),
|
||||
};
|
||||
|
||||
var recoveryPipeline = PolicyFactory.BuildListenerRecovery(
|
||||
@@ -405,6 +412,19 @@ internal sealed partial class ConfigReconciler : IDisposable
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — constructs a <see cref="ResponseCache"/> only when at least one resolved
|
||||
/// tag in <paramref name="map"/> opts in (<see cref="BcdTag.CacheTtlMs"/> > 0).
|
||||
/// Returns <c>null</c> otherwise so the no-cache path is byte-identical to Phase 10.
|
||||
/// </summary>
|
||||
private static ResponseCache? BuildCacheIfNeeded(BcdTagMap map, CacheOptions opts)
|
||||
{
|
||||
foreach (var t in map.All)
|
||||
if (t.CacheTtlMs > 0)
|
||||
return new ResponseCache(opts.MaxEntriesPerPlc, opts.EvictionIntervalMs);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ComputeGlobalTagDelta(BcdTagListOptions before, BcdTagListOptions after)
|
||||
{
|
||||
// Count entries in before but not in after (removed), plus entries in after
|
||||
|
||||
@@ -78,8 +78,10 @@ public sealed record ReloadPlan(
|
||||
// Tag-map change → reseat (swap context, keep socket).
|
||||
// We must build both maps to compare them structurally.
|
||||
// Compute happens after validation so Build should never return errors here.
|
||||
var oldMap = BcdTagMapBuilder.Build(current.BcdTags, plcOld.BcdTags).Map;
|
||||
var newMap = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags).Map;
|
||||
// Phase 11: include DefaultCacheTtlMs in the build so a per-PLC default change
|
||||
// is detected by TagMapsEqual via the per-tag CacheTtlMs delta.
|
||||
var oldMap = BcdTagMapBuilder.Build(current.BcdTags, plcOld.BcdTags, plcOld.DefaultCacheTtlMs).Map;
|
||||
var newMap = BcdTagMapBuilder.Build(next.BcdTags, plcNew.BcdTags, plcNew.DefaultCacheTtlMs).Map;
|
||||
|
||||
if (!TagMapsEqual(oldMap, newMap))
|
||||
toReseat.Add((name, newMap));
|
||||
@@ -94,7 +96,9 @@ public sealed record ReloadPlan(
|
||||
|
||||
/// <summary>
|
||||
/// Structural equality between two <see cref="BcdTagMap"/> instances: same set of
|
||||
/// (Address, Width) pairs. Order doesn't matter — we compare as sets.
|
||||
/// (Address, Width, CacheTtlMs) triples. Order doesn't matter — we compare as sets.
|
||||
/// Phase 11 includes <see cref="BcdTag.CacheTtlMs"/> in the comparison so a per-tag
|
||||
/// or per-PLC default TTL change reseats the context (which flushes the cache).
|
||||
/// </summary>
|
||||
private static bool TagMapsEqual(BcdTagMap a, BcdTagMap b)
|
||||
{
|
||||
@@ -106,6 +110,8 @@ public sealed record ReloadPlan(
|
||||
return false;
|
||||
if (tag.Width != bTag.Width)
|
||||
return false;
|
||||
if (tag.CacheTtlMs != bTag.CacheTtlMs)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -77,12 +77,54 @@ internal static class ReloadValidator
|
||||
// well-formedness; we must not duplicate its validation logic here.
|
||||
foreach (var plc in next.Plcs)
|
||||
{
|
||||
var result = BcdTagMapBuilder.Build(next.BcdTags, plc.BcdTags);
|
||||
var result = BcdTagMapBuilder.Build(next.BcdTags, plc.BcdTags, plc.DefaultCacheTtlMs);
|
||||
foreach (var err in result.Errors)
|
||||
errs.Add($"Plc '{plc.Name}': BCD tag map error ({err.Kind}): {err.Message}");
|
||||
}
|
||||
|
||||
// ── 5. Cache TTL bounds (Phase 11) ────────────────────────────────────
|
||||
// The MbproxyOptionsValidator catches these at schema time too, but ReloadValidator
|
||||
// is the gate that the hot-reload path consults directly so re-checking here keeps
|
||||
// both paths internally consistent (and the validator runs against tag-map-resolved
|
||||
// BcdTag.CacheTtlMs values too).
|
||||
bool allowLongTtl = next.Cache.AllowLongTtl;
|
||||
foreach (var tag in next.BcdTags.Global)
|
||||
{
|
||||
CheckTtl(errs, $"BcdTags.Global Address {tag.Address}", tag.CacheTtlMs, allowLongTtl);
|
||||
}
|
||||
foreach (var plc in next.Plcs)
|
||||
{
|
||||
if (plc.DefaultCacheTtlMs > 60_000 && !allowLongTtl)
|
||||
errs.Add(
|
||||
$"Plc '{plc.Name}': DefaultCacheTtlMs={plc.DefaultCacheTtlMs} exceeds 60_000 ms " +
|
||||
"without Cache.AllowLongTtl=true.");
|
||||
else if (plc.DefaultCacheTtlMs < 0)
|
||||
errs.Add($"Plc '{plc.Name}': DefaultCacheTtlMs must be >= 0; got {plc.DefaultCacheTtlMs}.");
|
||||
|
||||
if (plc.BcdTags?.Add is { } addList)
|
||||
{
|
||||
foreach (var tag in addList)
|
||||
CheckTtl(errs, $"Plc '{plc.Name}' BcdTags.Add Address {tag.Address}",
|
||||
tag.CacheTtlMs, allowLongTtl);
|
||||
}
|
||||
}
|
||||
if (next.Cache.MaxEntriesPerPlc < 0)
|
||||
errs.Add($"Cache.MaxEntriesPerPlc must be >= 0; got {next.Cache.MaxEntriesPerPlc}.");
|
||||
if (next.Cache.EvictionIntervalMs < 0)
|
||||
errs.Add($"Cache.EvictionIntervalMs must be >= 0; got {next.Cache.EvictionIntervalMs}.");
|
||||
|
||||
errors = errs;
|
||||
return errs.Count == 0;
|
||||
}
|
||||
|
||||
private static void CheckTtl(List<string> errs, string context, int? ttl, bool allowLongTtl)
|
||||
{
|
||||
if (ttl is null) return;
|
||||
int v = ttl.Value;
|
||||
if (v < 0)
|
||||
errs.Add($"{context}: CacheTtlMs must be >= 0; got {v}.");
|
||||
else if (v > 60_000 && !allowLongTtl)
|
||||
errs.Add(
|
||||
$"{context}: CacheTtlMs={v} exceeds 60_000 ms without Cache.AllowLongTtl=true.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,12 @@ public sealed class BcdTagOptions
|
||||
{
|
||||
public ushort Address { get; init; }
|
||||
public byte Width { get; init; } // 16 or 32
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — optional opt-in to the response cache. Null (default) means
|
||||
/// "unset" and falls back to the per-PLC <see cref="PlcOptions.DefaultCacheTtlMs"/>;
|
||||
/// 0 explicitly disables caching for this tag even when the PLC default is non-zero.
|
||||
/// Positive values cap the staleness window in milliseconds.
|
||||
/// </summary>
|
||||
public int? CacheTtlMs { get; init; }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,41 @@ public sealed class MbproxyOptions
|
||||
public int AdminPort { get; init; } = 8080;
|
||||
public ConnectionOptions Connection { get; init; } = new();
|
||||
public ResilienceOptions Resilience { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — service-wide response-cache settings. The cache is opt-in
|
||||
/// per-tag (<see cref="BcdTagOptions.CacheTtlMs"/>); this section configures the
|
||||
/// safety knobs that gate / bound the cache.
|
||||
/// </summary>
|
||||
public CacheOptions Cache { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — service-wide response-cache knobs. The cache is OFF by default for every
|
||||
/// tag; this section governs the limits when an operator opts a tag in.
|
||||
/// </summary>
|
||||
public sealed class CacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gate for any <see cref="BcdTagOptions.CacheTtlMs"/> greater than 60_000 ms.
|
||||
/// Defaults to <c>false</c> so accidentally-stale-for-an-hour deployments are caught
|
||||
/// at reload validation. Set to <c>true</c> to explicitly allow long TTLs.
|
||||
/// </summary>
|
||||
public bool AllowLongTtl { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// LRU cap on the number of entries per-PLC. Past this cap, the next insert evicts
|
||||
/// the least-recently-used entry. Defaults to 1000 — comfortable for a 54-PLC fleet
|
||||
/// with short TTLs.
|
||||
/// </summary>
|
||||
public int MaxEntriesPerPlc { get; init; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Background eviction loop tick in milliseconds. Each tick scans the cache and
|
||||
/// removes entries past their <c>ExpiresAtUtc</c>. Defaults to 5000 ms; values below
|
||||
/// 100 ms are clamped at 100 to avoid pathologically tight loops.
|
||||
/// </summary>
|
||||
public int EvictionIntervalMs { get; init; } = 5000;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -20,28 +55,59 @@ public sealed class MbproxyOptionsValidator : IValidateOptions<MbproxyOptions>
|
||||
public ValidateOptionsResult Validate(string? name, MbproxyOptions options)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
bool allowLongTtl = options.Cache.AllowLongTtl;
|
||||
|
||||
foreach (var tag in options.BcdTags.Global)
|
||||
{
|
||||
if (tag.Width != 16 && tag.Width != 32)
|
||||
errors.Add($"BcdTags.Global: Address {tag.Address} has invalid Width {tag.Width}; must be 16 or 32.");
|
||||
ValidateCacheTtl(errors, $"BcdTags.Global Address {tag.Address}", tag.CacheTtlMs, allowLongTtl);
|
||||
}
|
||||
|
||||
for (int i = 0; i < options.Plcs.Count; i++)
|
||||
{
|
||||
var plc = options.Plcs[i];
|
||||
|
||||
// Phase 11 — per-PLC default TTL bounds.
|
||||
if (plc.DefaultCacheTtlMs < 0)
|
||||
errors.Add($"Plcs[{i}] ({plc.Name}): DefaultCacheTtlMs must be >= 0.");
|
||||
else if (plc.DefaultCacheTtlMs > 60_000 && !allowLongTtl)
|
||||
errors.Add(
|
||||
$"Plcs[{i}] ({plc.Name}): DefaultCacheTtlMs={plc.DefaultCacheTtlMs} exceeds the 60_000 ms safety cap; " +
|
||||
$"set Cache.AllowLongTtl=true to opt in.");
|
||||
|
||||
if (plc.BcdTags is { } overrides)
|
||||
{
|
||||
foreach (var tag in overrides.Add)
|
||||
{
|
||||
if (tag.Width != 16 && tag.Width != 32)
|
||||
errors.Add($"Plcs[{i}] ({plc.Name}): BcdTags.Add Address {tag.Address} has invalid Width {tag.Width}; must be 16 or 32.");
|
||||
ValidateCacheTtl(errors, $"Plcs[{i}] ({plc.Name}) BcdTags.Add Address {tag.Address}",
|
||||
tag.CacheTtlMs, allowLongTtl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache section ranges.
|
||||
if (options.Cache.MaxEntriesPerPlc < 0)
|
||||
errors.Add($"Cache.MaxEntriesPerPlc must be >= 0; got {options.Cache.MaxEntriesPerPlc}.");
|
||||
if (options.Cache.EvictionIntervalMs < 0)
|
||||
errors.Add($"Cache.EvictionIntervalMs must be >= 0; got {options.Cache.EvictionIntervalMs}.");
|
||||
|
||||
return errors.Count > 0
|
||||
? ValidateOptionsResult.Fail(errors)
|
||||
: ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
private static void ValidateCacheTtl(List<string> errors, string context, int? ttlMs, bool allowLongTtl)
|
||||
{
|
||||
if (ttlMs is null) return;
|
||||
int value = ttlMs.Value;
|
||||
if (value < 0)
|
||||
errors.Add($"{context}: CacheTtlMs must be >= 0; got {value}.");
|
||||
else if (value > 60_000 && !allowLongTtl)
|
||||
errors.Add(
|
||||
$"{context}: CacheTtlMs={value} exceeds the 60_000 ms safety cap; " +
|
||||
$"set Cache.AllowLongTtl=true to opt in.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,12 @@ public sealed class PlcOptions
|
||||
public int Port { get; init; } = 502;
|
||||
|
||||
public PlcBcdOverrides? BcdTags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — per-PLC default cache TTL applied to any tag whose explicit
|
||||
/// <see cref="BcdTagOptions.CacheTtlMs"/> is unset (null). 0 (the default) means
|
||||
/// "no caching by default at this PLC". Per-tag values always win over the per-PLC
|
||||
/// default when set; an explicit zero on a tag still disables caching for that tag.
|
||||
/// </summary>
|
||||
public int DefaultCacheTtlMs { get; init; } = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Mbproxy.Proxy.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the per-PLC response cache.
|
||||
///
|
||||
/// <para><b><see cref="PduBytes"/></b> is the POST-rewriter response PDU body (FC byte +
|
||||
/// byteCount + register data; no MBAP header). The cache stores rewriter-decoded bytes so
|
||||
/// hits never re-invoke the BCD rewriter — both a CPU optimisation and a correctness
|
||||
/// guarantee against future rewriter changes accidentally transforming an already-decoded
|
||||
/// payload.</para>
|
||||
///
|
||||
/// <para><b><see cref="LastUsedTick"/></b> is the LRU ordering counter. The cache assigns
|
||||
/// each touch (hit or fresh insert) the next value from <see cref="ResponseCache"/>'s
|
||||
/// monotonic ticker; LRU eviction picks the entry with the smallest tick. Using a long
|
||||
/// instead of <see cref="DateTimeOffset.UtcNow"/> on every access keeps the hot path free
|
||||
/// of clock calls and works correctly even if the wall clock moves backward.</para>
|
||||
/// </summary>
|
||||
internal sealed record CacheEntry(
|
||||
byte[] PduBytes,
|
||||
DateTimeOffset CachedAtUtc,
|
||||
DateTimeOffset ExpiresAtUtc,
|
||||
int Length,
|
||||
long LastUsedTick);
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace Mbproxy.Proxy.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Pure address-range-overlap matcher for FC06 / FC16 write invalidation.
|
||||
///
|
||||
/// <para>Half-open interval math: a write covering <c>[w, w + writeQty)</c> overlaps an
|
||||
/// entry covering <c>[s, s + qty)</c> iff <c>w < s + qty AND s < w + writeQty</c>.
|
||||
/// Adjacent-but-not-overlapping (write to <c>[10..15)</c> vs cached <c>[15..20)</c>) does
|
||||
/// NOT match — register 15 is not in the cached range.</para>
|
||||
///
|
||||
/// <para>Scope is restricted to FC03 / FC04 keys; we never cache writes so invalidation
|
||||
/// only applies to read entries. Different <c>unitId</c> bytes never invalidate each other
|
||||
/// (multi-drop / gateway personalities behind a shared socket).</para>
|
||||
/// </summary>
|
||||
internal static class CacheInvalidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns every <see cref="CacheKey"/> in <paramref name="haystack"/> that satisfies:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="CacheKey.UnitId"/> equals <paramref name="unitId"/>.</description></item>
|
||||
/// <item><description><see cref="CacheKey.Fc"/> is 0x03 or 0x04 (only read entries are evicted).</description></item>
|
||||
/// <item><description>The key's range <c>[StartAddress, StartAddress + Qty)</c> overlaps
|
||||
/// the write range <c>[writeStart, writeStart + writeQty)</c>.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Pure function; the returned enumeration is materialised so callers can mutate
|
||||
/// the haystack while iterating the result without raising "collection modified."</para>
|
||||
/// </summary>
|
||||
public static IEnumerable<CacheKey> FindOverlapping(
|
||||
IReadOnlyCollection<CacheKey> haystack,
|
||||
byte unitId,
|
||||
ushort writeStart,
|
||||
ushort writeQty)
|
||||
{
|
||||
// writeQty = 0 — a degenerate write that covers no registers. Nothing to invalidate.
|
||||
if (writeQty == 0) return Array.Empty<CacheKey>();
|
||||
|
||||
int writeEnd = writeStart + writeQty; // half-open upper bound
|
||||
|
||||
var hits = new List<CacheKey>();
|
||||
foreach (var key in haystack)
|
||||
{
|
||||
if (key.UnitId != unitId) continue;
|
||||
if (key.Fc != 0x03 && key.Fc != 0x04) continue;
|
||||
|
||||
int keyEnd = key.StartAddress + key.Qty;
|
||||
// Overlap iff writeStart < keyEnd AND key.StartAddress < writeEnd.
|
||||
if (writeStart < keyEnd && key.StartAddress < writeEnd)
|
||||
hits.Add(key);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
|
||||
namespace Mbproxy.Proxy.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Hash key for the per-PLC <see cref="ResponseCache"/>. Structurally identical to
|
||||
/// Phase 10's <see cref="CoalescingKey"/> — both keys discriminate the same dimensions
|
||||
/// (UnitId, FunctionCode, StartAddress, Quantity), but the two type aliases live in
|
||||
/// different namespaces so the two phases can evolve independently without one shaping
|
||||
/// the other's API surface.
|
||||
///
|
||||
/// <para><b>Equality semantics:</b> record-struct value equality. FC03 and FC04 produce
|
||||
/// different keys for the same address (different Modbus tables); different
|
||||
/// <see cref="UnitId"/> bytes never share a key (different PLC personalities behind a
|
||||
/// shared socket); reads of different <see cref="Qty"/> never share a key (the responses
|
||||
/// carry different register counts and would not be interchangeable on a fan-out).</para>
|
||||
///
|
||||
/// <para><b>Scope:</b> only FC03 (Read Holding Registers) and FC04 (Read Input Registers)
|
||||
/// are cacheable. FC06 / FC16 writes invalidate cache entries by ADDRESS RANGE OVERLAP
|
||||
/// rather than exact-key match — see <see cref="CacheInvalidator"/>.</para>
|
||||
/// </summary>
|
||||
internal readonly record struct CacheKey(
|
||||
byte UnitId,
|
||||
byte Fc,
|
||||
ushort StartAddress,
|
||||
ushort Qty);
|
||||
@@ -0,0 +1,77 @@
|
||||
namespace Mbproxy.Proxy.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated <see cref="LoggerMessage"/> definitions for the Phase-11 response
|
||||
/// cache. Event names are stable — do not rename without updating <c>docs/design.md</c>'s
|
||||
/// Logging event-name table.
|
||||
///
|
||||
/// <para>Levels are conservative — a busy PLC under steady cache pressure would emit one
|
||||
/// Hit / Miss per FC03/FC04 request. The counters surface the same data at far lower cost
|
||||
/// for monitoring; Debug-level events are present for incident-time diagnosis only.</para>
|
||||
/// </summary>
|
||||
internal static partial class CacheLogEvents
|
||||
{
|
||||
[LoggerMessage(
|
||||
EventId = 140,
|
||||
EventName = "mbproxy.cache.hit",
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Cache hit: Plc={Plc} Unit={UnitId} Fc={Fc} Start={Start} Qty={Qty}")]
|
||||
public static partial void Hit(
|
||||
ILogger logger,
|
||||
string plc,
|
||||
byte unitId,
|
||||
byte fc,
|
||||
ushort start,
|
||||
ushort qty);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 141,
|
||||
EventName = "mbproxy.cache.miss",
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Cache miss: Plc={Plc} Unit={UnitId} Fc={Fc} Start={Start} Qty={Qty}")]
|
||||
public static partial void Miss(
|
||||
ILogger logger,
|
||||
string plc,
|
||||
byte unitId,
|
||||
byte fc,
|
||||
ushort start,
|
||||
ushort qty);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 142,
|
||||
EventName = "mbproxy.cache.store",
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Cache store: Plc={Plc} Unit={UnitId} Fc={Fc} Start={Start} Qty={Qty} TtlMs={TtlMs}")]
|
||||
public static partial void Store(
|
||||
ILogger logger,
|
||||
string plc,
|
||||
byte unitId,
|
||||
byte fc,
|
||||
ushort start,
|
||||
ushort qty,
|
||||
int ttlMs);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 143,
|
||||
EventName = "mbproxy.cache.invalidated",
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Cache invalidated: Plc={Plc} Unit={UnitId} WriteStart={WriteStart} WriteQty={WriteQty} Count={Count}")]
|
||||
public static partial void Invalidated(
|
||||
ILogger logger,
|
||||
string plc,
|
||||
byte unitId,
|
||||
ushort writeStart,
|
||||
ushort writeQty,
|
||||
int count);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 144,
|
||||
EventName = "mbproxy.cache.flushed",
|
||||
Level = LogLevel.Information,
|
||||
Message = "Cache flushed: Plc={Plc} Reason={Reason} Count={Count}")]
|
||||
public static partial void Flushed(
|
||||
ILogger logger,
|
||||
string plc,
|
||||
string reason,
|
||||
int count);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
namespace Mbproxy.Proxy.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Per-PLC opt-in response cache for FC03 / FC04 read responses. Phase 11.
|
||||
///
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,4 +38,5 @@ internal sealed record InFlightRequest(
|
||||
ushort StartAddress,
|
||||
ushort Qty,
|
||||
IReadOnlyList<InterestedParty> InterestedParties,
|
||||
DateTimeOffset SentAtUtc);
|
||||
DateTimeOffset SentAtUtc,
|
||||
int ResolvedCacheTtlMs = 0);
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Channels;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy.Cache;
|
||||
using Polly;
|
||||
|
||||
namespace Mbproxy.Proxy.Multiplexing;
|
||||
@@ -102,6 +103,12 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
_backendConnectPipeline = backendConnectPipeline;
|
||||
_coalescingOptions = coalescingOptions ?? (static () => new ReadCoalescingOptions());
|
||||
|
||||
// Phase 11 — register the per-PLC cache as the live stats source for the snapshot
|
||||
// path. Cache may be null when the per-PLC context has not been wired with one
|
||||
// (every tag uncached, or unit tests).
|
||||
if (_ctx.Cache is not null)
|
||||
_ctx.Counters.SetCacheStatsProvider(new CacheStatsAdapter(_ctx.Cache));
|
||||
|
||||
// Register this multiplexer as the live telemetry source for the PLC's counters.
|
||||
_ctx.Counters.SetMultiplexProvider(this);
|
||||
|
||||
@@ -177,6 +184,7 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
// Stop the counters provider link so a status snapshot during teardown doesn't
|
||||
// see live-but-soon-to-be-empty internal state.
|
||||
_ctx.Counters.SetMultiplexProvider(null);
|
||||
_ctx.Counters.SetCacheStatsProvider(null);
|
||||
|
||||
await _disposeCts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -450,6 +458,57 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
frame.AsSpan(MbapFrame.HeaderSize, pduBodyLen),
|
||||
responseCtx);
|
||||
|
||||
// Phase 11 — post-rewriter cache update:
|
||||
// * FC03/FC04 successful responses are stored when the request was
|
||||
// cache-eligible (resolvedTtlMs > 0).
|
||||
// * FC06/FC16 successful responses invalidate every cached entry whose
|
||||
// address range overlaps the write.
|
||||
if (_ctx.Cache is { } postCache)
|
||||
{
|
||||
byte fcInResponse = frame[MbapFrame.HeaderSize]; // post-rewriter, but the FC byte is never rewritten
|
||||
bool isException = (fcInResponse & 0x80) != 0;
|
||||
|
||||
if (!isException)
|
||||
{
|
||||
if (inFlight.Fc is 0x03 or 0x04 && inFlight.ResolvedCacheTtlMs > 0)
|
||||
{
|
||||
// Snapshot the post-rewriter PDU body so the cached entry is
|
||||
// independent of this frame's lifetime.
|
||||
byte[] pduSnapshot = new byte[pduBodyLen];
|
||||
Buffer.BlockCopy(frame, MbapFrame.HeaderSize, pduSnapshot, 0, pduBodyLen);
|
||||
|
||||
var cacheKey = new CacheKey(
|
||||
inFlight.UnitId, inFlight.Fc,
|
||||
inFlight.StartAddress, inFlight.Qty);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entry = new CacheEntry(
|
||||
PduBytes: pduSnapshot,
|
||||
CachedAtUtc: now,
|
||||
ExpiresAtUtc: now.AddMilliseconds(inFlight.ResolvedCacheTtlMs),
|
||||
Length: pduSnapshot.Length,
|
||||
LastUsedTick: 0); // ResponseCache.Set stamps the real tick
|
||||
postCache.Set(cacheKey, entry);
|
||||
|
||||
CacheLogEvents.Store(_logger, _plc.Name,
|
||||
inFlight.UnitId, inFlight.Fc,
|
||||
inFlight.StartAddress, inFlight.Qty,
|
||||
inFlight.ResolvedCacheTtlMs);
|
||||
}
|
||||
else if (inFlight.Fc is 0x06 or 0x10)
|
||||
{
|
||||
int invalidated = postCache.Invalidate(
|
||||
inFlight.UnitId, inFlight.StartAddress, inFlight.Qty);
|
||||
if (invalidated > 0)
|
||||
{
|
||||
_ctx.Counters.AddCacheInvalidations(invalidated);
|
||||
CacheLogEvents.Invalidated(_logger, _plc.Name,
|
||||
inFlight.UnitId, inFlight.StartAddress, inFlight.Qty,
|
||||
invalidated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fan out to each interested party with their original TxId restored.
|
||||
// Phase 9: always exactly one party. Phase 10: N parties (read coalescing).
|
||||
// Note: the InFlightByKey TryRemove above (for FC03/FC04) guarantees no
|
||||
@@ -506,15 +565,6 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
// Ensure backend is connected. Failure here means we cannot service the request;
|
||||
// close the upstream pipe (consistent with the 1:1 model's behaviour on connect
|
||||
// failure).
|
||||
if (!await EnsureBackendConnectedAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
try { await pipe.DisposeAsync().ConfigureAwait(false); } catch { /* best effort */ }
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.Length < MbapFrame.HeaderSize)
|
||||
return;
|
||||
|
||||
@@ -522,9 +572,11 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
out ushort originalTxId, out _, out _, out byte unitId))
|
||||
return;
|
||||
|
||||
// Parse the PDU FC + start/qty (for FC03/04) — needed for both the coalescing-key
|
||||
// path and the response correlation slot. FC06/FC16 (writes) keep startAddr/qty = 0;
|
||||
// they bypass coalescing entirely.
|
||||
// Parse the PDU FC + start/qty. FC03/FC04 reads use start/qty for the coalescing key
|
||||
// and (Phase 11) for the cache lookup. FC06 writes carry [addr][value]; we treat qty
|
||||
// as 1 for invalidation. FC16 carries [start][qty][byteCount]...; qty is the write
|
||||
// span used for cache invalidation. Phase 11: FC06/FC16 start/qty drive cache
|
||||
// invalidation by overlap rather than exact key.
|
||||
int pduOffset = MbapFrame.HeaderSize;
|
||||
byte fcByte = frame.Length > pduOffset ? frame[pduOffset] : (byte)0;
|
||||
ushort startAddr = 0;
|
||||
@@ -534,6 +586,56 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
startAddr = (ushort)((frame[pduOffset + 1] << 8) | frame[pduOffset + 2]);
|
||||
qty = (ushort)((frame[pduOffset + 3] << 8) | frame[pduOffset + 4]);
|
||||
}
|
||||
else if (fcByte == 0x06 && frame.Length >= pduOffset + 5)
|
||||
{
|
||||
// FC06 = Write Single Register. PDU: [fc=06][addrHi][addrLo][valHi][valLo].
|
||||
// For cache invalidation we represent this as qty=1 at addr.
|
||||
startAddr = (ushort)((frame[pduOffset + 1] << 8) | frame[pduOffset + 2]);
|
||||
qty = 1;
|
||||
}
|
||||
else if (fcByte == 0x10 && frame.Length >= pduOffset + 5)
|
||||
{
|
||||
// FC16 = Write Multiple Registers. PDU: [fc=10][startHi][startLo][qtyHi][qtyLo][byteCount]...
|
||||
startAddr = (ushort)((frame[pduOffset + 1] << 8) | frame[pduOffset + 2]);
|
||||
qty = (ushort)((frame[pduOffset + 3] << 8) | frame[pduOffset + 4]);
|
||||
}
|
||||
|
||||
// Phase 11 — response-cache path. Cache check happens BEFORE coalescing AND before
|
||||
// we attempt to bring up the backend connection. A hit short-circuits everything,
|
||||
// including the EnsureBackendConnectedAsync call — operators with all reads cached
|
||||
// and the backend down still get served (the cache survives backend disconnects per
|
||||
// the design contract). The cache only fires for FC03/FC04 and only when the read
|
||||
// range's resolved TTL > 0.
|
||||
int resolvedCacheTtlMs = 0;
|
||||
if (fcByte is 0x03 or 0x04 && _ctx.Cache is { } responseCache)
|
||||
{
|
||||
resolvedCacheTtlMs = _ctx.TagMap.ResolveCacheTtlMs(startAddr, qty);
|
||||
if (resolvedCacheTtlMs > 0)
|
||||
{
|
||||
var cacheKey = new CacheKey(unitId, fcByte, startAddr, qty);
|
||||
if (responseCache.TryGet(cacheKey, out var cached))
|
||||
{
|
||||
_ctx.Counters.IncrementCacheHit();
|
||||
CacheLogEvents.Hit(_logger, _plc.Name, unitId, fcByte, startAddr, qty);
|
||||
|
||||
byte[] hitFrame = BuildCacheHitFrame(originalTxId, unitId, cached.PduBytes);
|
||||
await pipe.SendResponseAsync(hitFrame, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
_ctx.Counters.IncrementCacheMiss();
|
||||
CacheLogEvents.Miss(_logger, _plc.Name, unitId, fcByte, startAddr, qty);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure backend is connected. Failure here means we cannot service the request;
|
||||
// close the upstream pipe (consistent with the 1:1 model's behaviour on connect
|
||||
// failure).
|
||||
if (!await EnsureBackendConnectedAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
try { await pipe.DisposeAsync().ConfigureAwait(false); } catch { /* best effort */ }
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 10 — read-coalescing path. Only FC03/FC04 are coalescable; only when the
|
||||
// feature is enabled in the live config. If the late-arriving request matches an
|
||||
@@ -573,7 +675,8 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
StartAddress: startAddr,
|
||||
Qty: qty,
|
||||
InterestedParties: new List<InterestedParty> { newParty },
|
||||
SentAtUtc: DateTimeOffset.UtcNow);
|
||||
SentAtUtc: DateTimeOffset.UtcNow,
|
||||
ResolvedCacheTtlMs: resolvedCacheTtlMs);
|
||||
}
|
||||
|
||||
var partyList = new List<InterestedParty>(capacity: 1) { newParty };
|
||||
@@ -583,7 +686,8 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
StartAddress: startAddr,
|
||||
Qty: qty,
|
||||
InterestedParties: partyList,
|
||||
SentAtUtc: DateTimeOffset.UtcNow);
|
||||
SentAtUtc: DateTimeOffset.UtcNow,
|
||||
ResolvedCacheTtlMs: resolvedCacheTtlMs);
|
||||
|
||||
if (!_correlation.TryAdd(proxyTxId, inFlight))
|
||||
{
|
||||
@@ -673,7 +777,8 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
StartAddress: startAddr,
|
||||
Qty: qty,
|
||||
InterestedParties: partyListNc,
|
||||
SentAtUtc: DateTimeOffset.UtcNow);
|
||||
SentAtUtc: DateTimeOffset.UtcNow,
|
||||
ResolvedCacheTtlMs: resolvedCacheTtlMs);
|
||||
|
||||
if (!_correlation.TryAdd(proxyTxIdFc, inFlightNc))
|
||||
{
|
||||
@@ -809,6 +914,20 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Adapter exposing a <see cref="Cache.ResponseCache"/>'s Count / ApproximateBytes as
|
||||
/// <see cref="ICacheStatsProvider"/> for the snapshot path. Kept as a sealed class so
|
||||
/// the cache type itself doesn't need to take an interface dependency on
|
||||
/// <see cref="ICacheStatsProvider"/>.
|
||||
/// </summary>
|
||||
private sealed class CacheStatsAdapter : ICacheStatsProvider
|
||||
{
|
||||
private readonly Cache.ResponseCache _cache;
|
||||
public CacheStatsAdapter(Cache.ResponseCache cache) => _cache = cache;
|
||||
public long EntryCount => _cache.Count;
|
||||
public long ApproximateBytes => _cache.ApproximateBytes;
|
||||
}
|
||||
|
||||
private static async Task<bool> FillAsync(
|
||||
Socket socket, byte[] buf, int offset, int count, CancellationToken ct)
|
||||
{
|
||||
@@ -824,6 +943,28 @@ internal sealed class PlcMultiplexer : IAsyncDisposable, IMultiplexCountersProvi
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — builds an MBAP-framed response from cached PDU bytes for the given
|
||||
/// upstream party. The cache stores POST-rewriter PDU bodies (no MBAP); each hit
|
||||
/// stamps a fresh MBAP header carrying the requesting party's original TxId so the
|
||||
/// response looks indistinguishable from a fresh backend reply.
|
||||
/// </summary>
|
||||
private static byte[] BuildCacheHitFrame(ushort originalTxId, byte unitId, byte[] cachedPdu)
|
||||
{
|
||||
// Length field covers UnitId(1) + PDU body. Capped by Modbus spec at 253-byte PDU.
|
||||
int pduLen = cachedPdu.Length;
|
||||
ushort length = (ushort)(1 + pduLen);
|
||||
var frame = new byte[MbapFrame.HeaderSize + pduLen];
|
||||
frame[0] = (byte)(originalTxId >> 8);
|
||||
frame[1] = (byte)(originalTxId & 0xFF);
|
||||
frame[2] = 0; frame[3] = 0;
|
||||
frame[4] = (byte)(length >> 8);
|
||||
frame[5] = (byte)(length & 0xFF);
|
||||
frame[6] = unitId;
|
||||
Buffer.BlockCopy(cachedPdu, 0, frame, MbapFrame.HeaderSize, pduLen);
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static byte[] BuildExceptionFrame(ushort originalTxId, byte unitId, byte fc, byte exceptionCode)
|
||||
{
|
||||
// Modbus exception PDU = [fc | 0x80][exceptionCode].
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Proxy.Cache;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
|
||||
namespace Mbproxy.Proxy;
|
||||
@@ -44,6 +45,14 @@ internal class PerPlcContext : PduContext
|
||||
/// </summary>
|
||||
internal InFlightRequest? CurrentRequest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — optional per-PLC response cache. <c>null</c> on contexts that opt out
|
||||
/// (every BCD tag has <see cref="BcdTag.CacheTtlMs"/> = 0) or in unit tests that don't
|
||||
/// exercise the cache. The multiplexer constructs and disposes the cache alongside
|
||||
/// itself.
|
||||
/// </summary>
|
||||
internal ResponseCache? Cache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns a shallow clone of this context with <see cref="CurrentRequest"/> set to
|
||||
/// <paramref name="req"/>. The clone is cheap (one allocation per response) and avoids
|
||||
@@ -56,5 +65,6 @@ internal class PerPlcContext : PduContext
|
||||
Counters = Counters,
|
||||
Logger = Logger,
|
||||
CurrentRequest = req,
|
||||
Cache = Cache,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,7 +97,34 @@ public sealed record CounterSnapshot(
|
||||
/// attached upstream pipe had already disconnected. A spike is a churn indicator; the
|
||||
/// metric itself is informational (Tier 2 in <c>docs/kpi.md</c>).
|
||||
/// </summary>
|
||||
long CoalescedResponseToDeadUpstream);
|
||||
long CoalescedResponseToDeadUpstream,
|
||||
/// <summary>
|
||||
/// Phase 11 — cumulative count of FC03/FC04 requests served from the response cache.
|
||||
/// <c>CacheHitCount + CacheMissCount</c> equals total FC03/FC04 requests whose resolved
|
||||
/// TTL was > 0 (cache-eligible). Reads against tags with TTL = 0 increment neither.
|
||||
/// </summary>
|
||||
long CacheHitCount,
|
||||
/// <summary>
|
||||
/// Phase 11 — cumulative count of cache-eligible FC03/FC04 requests that fell through
|
||||
/// to coalescing / backend (no fresh entry was present or the entry had expired).
|
||||
/// </summary>
|
||||
long CacheMissCount,
|
||||
/// <summary>
|
||||
/// Phase 11 — cumulative count of cache entries invalidated by overlapping FC06/FC16
|
||||
/// write responses. A high rate suggests caching is fighting writes; consider lower
|
||||
/// TTLs on cache-overlapping tags.
|
||||
/// </summary>
|
||||
long CacheInvalidations,
|
||||
/// <summary>
|
||||
/// Phase 11 — point-in-time snapshot of the per-PLC <see cref="Cache.ResponseCache"/>
|
||||
/// entry count. Read on the snapshot path; 0 when no cache is wired.
|
||||
/// </summary>
|
||||
long CacheEntryCount,
|
||||
/// <summary>
|
||||
/// Phase 11 — point-in-time approximation of cached PDU bytes for this PLC. Sum of
|
||||
/// <see cref="Cache.CacheEntry.Length"/> across entries. Read on the snapshot path.
|
||||
/// </summary>
|
||||
long CacheBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe per-PLC counters backed by <see cref="System.Threading.Interlocked"/> longs.
|
||||
@@ -137,6 +164,16 @@ internal sealed class ProxyCounters
|
||||
private long _coalescedMissCount;
|
||||
private long _coalescedResponseToDeadUpstream;
|
||||
|
||||
// Phase 11 — response-cache counters. Hit + Miss = total cache-eligible FC03/FC04.
|
||||
private long _cacheHitCount;
|
||||
private long _cacheMissCount;
|
||||
private long _cacheInvalidations;
|
||||
|
||||
// Phase 11 — live cache state pulled from a per-PLC ResponseCache on each snapshot.
|
||||
// The multiplexer registers a single provider via SetCacheStatsProvider so the status
|
||||
// page sees current entry-count / bytes without a separate poll.
|
||||
private volatile ICacheStatsProvider? _cacheStatsProvider;
|
||||
|
||||
// Phase 9: live state pulled from the multiplexer's allocator/map/queue on each
|
||||
// snapshot. The multiplexer registers a single provider via SetMultiplexProvider.
|
||||
// We use a volatile reference for lock-free read on the snapshot path.
|
||||
@@ -244,6 +281,25 @@ internal sealed class ProxyCounters
|
||||
public void IncrementCoalescedResponseToDeadUpstream()
|
||||
=> Interlocked.Increment(ref _coalescedResponseToDeadUpstream);
|
||||
|
||||
/// <summary>Phase 11 — records one FC03/FC04 cache hit.</summary>
|
||||
public void IncrementCacheHit()
|
||||
=> Interlocked.Increment(ref _cacheHitCount);
|
||||
|
||||
/// <summary>Phase 11 — records one cache-eligible FC03/FC04 read that missed.</summary>
|
||||
public void IncrementCacheMiss()
|
||||
=> Interlocked.Increment(ref _cacheMissCount);
|
||||
|
||||
/// <summary>Phase 11 — records <paramref name="n"/> cache entries invalidated by a write.</summary>
|
||||
public void AddCacheInvalidations(int n)
|
||||
=> Interlocked.Add(ref _cacheInvalidations, n);
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — wires the per-PLC <see cref="Cache.ResponseCache"/> as the live stats
|
||||
/// source for the snapshot path. Pass <c>null</c> to detach during disposal.
|
||||
/// </summary>
|
||||
internal void SetCacheStatsProvider(ICacheStatsProvider? provider)
|
||||
=> _cacheStatsProvider = provider;
|
||||
|
||||
/// <summary>
|
||||
/// CAS-updates the peak in-flight high-water mark. Called on every successful
|
||||
/// allocation by the multiplexer. Phase 9.
|
||||
@@ -328,6 +384,10 @@ internal sealed class ProxyCounters
|
||||
long txWraps = provider?.TxIdWraps ?? 0;
|
||||
long queueDepth = provider?.BackendQueueDepth ?? 0;
|
||||
|
||||
var cacheProvider = _cacheStatsProvider;
|
||||
long cacheEntries = cacheProvider?.EntryCount ?? 0;
|
||||
long cacheBytes = cacheProvider?.ApproximateBytes ?? 0;
|
||||
|
||||
return new(
|
||||
PdusForwarded: Interlocked.Read(ref _pdusForwarded),
|
||||
Fc03: Interlocked.Read(ref _fc03),
|
||||
@@ -357,7 +417,12 @@ internal sealed class ProxyCounters
|
||||
BackendQueueDepth: queueDepth,
|
||||
CoalescedHitCount: Interlocked.Read(ref _coalescedHitCount),
|
||||
CoalescedMissCount: Interlocked.Read(ref _coalescedMissCount),
|
||||
CoalescedResponseToDeadUpstream: Interlocked.Read(ref _coalescedResponseToDeadUpstream));
|
||||
CoalescedResponseToDeadUpstream: Interlocked.Read(ref _coalescedResponseToDeadUpstream),
|
||||
CacheHitCount: Interlocked.Read(ref _cacheHitCount),
|
||||
CacheMissCount: Interlocked.Read(ref _cacheMissCount),
|
||||
CacheInvalidations: Interlocked.Read(ref _cacheInvalidations),
|
||||
CacheEntryCount: cacheEntries,
|
||||
CacheBytes: cacheBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,3 +445,17 @@ internal interface IMultiplexCountersProvider
|
||||
/// <summary>Current depth of the outbound channel (frames queued for the backend writer).</summary>
|
||||
long BackendQueueDepth { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — read-only window into the per-PLC <see cref="Cache.ResponseCache"/>'s live
|
||||
/// state for the snapshot path. The multiplexer wires this on cache construction so the
|
||||
/// status page sees live counts without holding a direct reference to the cache.
|
||||
/// </summary>
|
||||
internal interface ICacheStatsProvider
|
||||
{
|
||||
/// <summary>Current cache entry count.</summary>
|
||||
long EntryCount { get; }
|
||||
|
||||
/// <summary>Approximation of cached PDU bytes (sum of <see cref="Cache.CacheEntry.Length"/>).</summary>
|
||||
long ApproximateBytes { get; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Mbproxy.Bcd;
|
||||
using Mbproxy.Configuration;
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy.Cache;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Mbproxy.Proxy.Supervision;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -70,7 +71,7 @@ internal sealed partial class ProxyWorker : BackgroundService
|
||||
|
||||
foreach (var plc in opts.Plcs)
|
||||
{
|
||||
var result = BcdTagMapBuilder.Build(opts.BcdTags, plc.BcdTags);
|
||||
var result = BcdTagMapBuilder.Build(opts.BcdTags, plc.BcdTags, plc.DefaultCacheTtlMs);
|
||||
|
||||
foreach (var warn in result.Warnings)
|
||||
_logger.LogWarning("[{Plc}] BCD tag map warning: {Message}", plc.Name, warn.Message);
|
||||
@@ -85,12 +86,22 @@ internal sealed partial class ProxyWorker : BackgroundService
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase 11 — construct a per-PLC response cache only when at least one
|
||||
// resolved tag opts in (CacheTtlMs > 0). Skipping cache construction for a
|
||||
// PLC with no cacheable tags keeps the no-cache path free of the eviction
|
||||
// timer and the per-call resolution cost, preserving "default behaviour =
|
||||
// Phase 10 unchanged" when no operator has opted any tag in.
|
||||
var cache = HasAnyCacheableTag(result.Map)
|
||||
? new ResponseCache(opts.Cache.MaxEntriesPerPlc, opts.Cache.EvictionIntervalMs)
|
||||
: null;
|
||||
|
||||
plcContexts[plc.Name] = new PerPlcContext
|
||||
{
|
||||
PlcName = plc.Name,
|
||||
TagMap = result.Map,
|
||||
Counters = new ProxyCounters(),
|
||||
Logger = _loggerFactory.CreateLogger($"Mbproxy.Proxy.BcdRewriter.{plc.Name}"),
|
||||
Cache = cache,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,6 +224,20 @@ internal sealed partial class ProxyWorker : BackgroundService
|
||||
|
||||
// ── Logging ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Phase 11 — returns <c>true</c> when at least one BcdTag in the resolved map has a
|
||||
/// positive <see cref="BcdTag.CacheTtlMs"/>. A PLC with no cacheable tags skips the
|
||||
/// <see cref="Mbproxy.Proxy.Cache.ResponseCache"/> entirely (no eviction timer, no
|
||||
/// per-call cache resolution cost), so the default-OFF deployment is byte-identical
|
||||
/// to a Phase-10 deployment.
|
||||
/// </summary>
|
||||
private static bool HasAnyCacheableTag(BcdTagMap map)
|
||||
{
|
||||
foreach (var t in map.All)
|
||||
if (t.CacheTtlMs > 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
[LoggerMessage(EventId = 1, EventName = "mbproxy.startup.ready",
|
||||
Level = LogLevel.Information,
|
||||
Message = "mbproxy service ready — ListenersBound={ListenersBound} PlcsConfigured={PlcsConfigured}")]
|
||||
|
||||
@@ -198,10 +198,20 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
|
||||
/// </summary>
|
||||
public Task ReplaceContextAsync(PerPlcContext newCtx, CancellationToken ct)
|
||||
{
|
||||
// Phase 11: dispose the outgoing context's response cache (if any) so its
|
||||
// eviction loop terminates. The "any tag-list reload flushes the affected PLC's
|
||||
// whole cache" doctrine is satisfied here — the new context constructs its own
|
||||
// fresh cache, the old cache is dropped wholesale.
|
||||
var oldCache = _currentContext?.Cache;
|
||||
|
||||
// Volatile write: the next PlcListener created in RunSupervisorAsync will see
|
||||
// the new context. The accept loop itself does not hold a direct reference to
|
||||
// _currentContext — it was captured at PlcListener construction time.
|
||||
_currentContext = newCtx;
|
||||
|
||||
if (oldCache is not null && !ReferenceEquals(oldCache, newCtx.Cache))
|
||||
oldCache.Dispose();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -376,6 +386,10 @@ internal sealed partial class PlcListenerSupervisor : IAsyncDisposable
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
|
||||
// Phase 11: dispose the response cache (if any) — its eviction timer would
|
||||
// otherwise outlive the supervisor.
|
||||
_currentContext?.Cache?.Dispose();
|
||||
|
||||
_supervisorCts.Dispose();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user