mbproxy: initial commit through Phase 9 (TxId multiplexing)
Adds the mbproxy service end-to-end. Phases 00-08 implement the production-ready single-listener / 1:1-backend transparent Modbus TCP proxy with bidirectional BCD rewriting for the ~54-PLC DL205/DL260 fleet. Phase 9 replaces the connection layer with a single backend socket per PLC plus MBAP TxId rewriting, lifting the H2-ECOM100's 4-concurrent-client cap as an operational ceiling. Phase 9 additions of note: - PlcMultiplexer + UpstreamPipe + TxIdAllocator + CorrelationMap - InFlightRequest with IReadOnlyList<InterestedParty> (load-bearing for Phase 10 read coalescing — do not collapse to a single field) - Per-request watchdog: surfaces Modbus exception 0x0B to upstream on BackendRequestTimeoutMs, defending against lost responses, dead-PLC paths, and pymodbus 3.13.0's concurrent-multiplexed- request bug (its ServerRequestHandler.last_pdu state race) - Status DTO + HTML gain inFlight / maxInFlight / txIdWraps / disconnectCascades / queueDepth (Tier 1.6 in docs/kpi.md) Tests: 263 unit + 38 E2E. Multiplexer correctness under truly concurrent backend traffic is proved against a stub backend in PlcMultiplexerTests; MultiplexerE2ETests paces requests so pymodbus 3.13's single-PDU framer stays in known-good mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
namespace Mbproxy;
|
||||
|
||||
/// <summary>
|
||||
/// Service-wide counters for the mbproxy host. Tracks reload accept/reject counts and
|
||||
/// timestamps so Phase 07's status page can surface them without coupling to the reconciler.
|
||||
///
|
||||
/// <para>Constructed once at DI startup and shared as a singleton. All writes are via
|
||||
/// dedicated methods that use <see cref="Interlocked"/> so reads from the status page
|
||||
/// are always coherent without locking.</para>
|
||||
/// </summary>
|
||||
public sealed class ServiceCounters
|
||||
{
|
||||
// LastReloadUtc: stored as ticks-since-epoch via Interlocked.Exchange.
|
||||
// 0 = "never reloaded". DateTimeOffset.MinValue.UtcTicks works as the sentinel
|
||||
// but 0 is simpler. DateTimeOffset.UtcNow.UtcTicks is always > 0 after 1970.
|
||||
private long _lastReloadUtcTicks; // 0 = never; Interlocked
|
||||
private int _reloadAppliedCount; // Interlocked
|
||||
private int _reloadRejectedCount; // Interlocked
|
||||
|
||||
/// <summary>Instant at which this service instance was constructed (service start proxy).</summary>
|
||||
public DateTimeOffset StartedAtUtc { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp of the last successfully applied hot-reload, or <c>null</c> if no
|
||||
/// reload has been accepted since the service started.
|
||||
/// </summary>
|
||||
public DateTimeOffset? LastReloadUtc
|
||||
{
|
||||
get
|
||||
{
|
||||
long ticks = Interlocked.Read(ref _lastReloadUtcTicks);
|
||||
return ticks == 0 ? null : new DateTimeOffset(ticks, TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Total number of configuration reloads accepted since service start.</summary>
|
||||
public int ReloadAppliedCount
|
||||
=> Interlocked.CompareExchange(ref _reloadAppliedCount, 0, 0);
|
||||
|
||||
/// <summary>Total number of configuration reloads rejected since service start.</summary>
|
||||
public int ReloadRejectedCount
|
||||
=> Interlocked.CompareExchange(ref _reloadRejectedCount, 0, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Records one accepted reload. Bumps <see cref="ReloadAppliedCount"/> and updates
|
||||
/// <see cref="LastReloadUtc"/>.
|
||||
/// </summary>
|
||||
public void RecordReloadApplied(DateTimeOffset timestamp)
|
||||
{
|
||||
Interlocked.Increment(ref _reloadAppliedCount);
|
||||
Interlocked.Exchange(ref _lastReloadUtcTicks, timestamp.UtcTicks);
|
||||
}
|
||||
|
||||
/// <summary>Bumps <see cref="ReloadRejectedCount"/>.</summary>
|
||||
public void RecordReloadRejected()
|
||||
=> Interlocked.Increment(ref _reloadRejectedCount);
|
||||
}
|
||||
Reference in New Issue
Block a user