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,157 @@
|
||||
using Mbproxy.Options;
|
||||
using Mbproxy.Proxy;
|
||||
using Mbproxy.Proxy.Multiplexing;
|
||||
using Mbproxy.Proxy.Supervision;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Mbproxy.Admin;
|
||||
|
||||
/// <summary>
|
||||
/// Pure orchestration: reads live state from injected singletons and builds a
|
||||
/// <see cref="StatusResponse"/> for <c>GET /</c> and <c>GET /status.json</c>.
|
||||
///
|
||||
/// <para>No I/O; no side effects. Constructed once via DI; <see cref="Build"/> is the
|
||||
/// only operation and may be called on any thread at any time.</para>
|
||||
/// </summary>
|
||||
internal sealed class StatusSnapshotBuilder
|
||||
{
|
||||
private readonly IOptionsMonitor<MbproxyOptions> _options;
|
||||
private readonly ServiceCounters _serviceCounters;
|
||||
private readonly AssemblyVersionAccessor _version;
|
||||
private readonly ProxyWorker _proxyWorker;
|
||||
|
||||
public StatusSnapshotBuilder(
|
||||
IOptionsMonitor<MbproxyOptions> options,
|
||||
ServiceCounters serviceCounters,
|
||||
AssemblyVersionAccessor version,
|
||||
ProxyWorker proxyWorker)
|
||||
{
|
||||
_options = options;
|
||||
_serviceCounters = serviceCounters;
|
||||
_version = version;
|
||||
_proxyWorker = proxyWorker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a point-in-time <see cref="StatusResponse"/>.
|
||||
/// Each counter is read atomically; no locks are held across the build.
|
||||
/// </summary>
|
||||
public StatusResponse Build()
|
||||
{
|
||||
var opts = _options.CurrentValue;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var started = _serviceCounters.StartedAtUtc;
|
||||
var uptime = (long)(now - started).TotalSeconds;
|
||||
var supervisors = _proxyWorker.Supervisors;
|
||||
|
||||
// ── Build per-PLC status rows ─────────────────────────────────────────
|
||||
var plcStatuses = new List<PlcStatus>(opts.Plcs.Count);
|
||||
int boundCount = 0;
|
||||
|
||||
foreach (var plc in opts.Plcs)
|
||||
{
|
||||
supervisors.TryGetValue(plc.Name, out var supervisor);
|
||||
|
||||
// Supervisor state
|
||||
SupervisorSnapshot? snap = supervisor?.Snapshot();
|
||||
string stateStr = snap?.State switch
|
||||
{
|
||||
SupervisorState.Bound => "bound",
|
||||
SupervisorState.Recovering => "recovering",
|
||||
_ => "stopped",
|
||||
};
|
||||
if (snap?.State == SupervisorState.Bound) boundCount++;
|
||||
|
||||
// Per-client snapshots
|
||||
var activeUpstreams = supervisor?.ActiveUpstreams ?? Array.Empty<UpstreamPipe>();
|
||||
var clientSnapshots = activeUpstreams
|
||||
.Select(p => new ClientSnapshot(
|
||||
Remote: p.RemoteEp?.ToString() ?? p.RemoteEp?.Address.ToString() ?? "?",
|
||||
ConnectedAtUtc: p.ConnectedAtUtc,
|
||||
PdusForwarded: p.PdusForwardedCount))
|
||||
.ToList();
|
||||
|
||||
// Counter snapshot
|
||||
var counters = supervisor?.CurrentCounters.Snapshot()
|
||||
?? new CounterSnapshot(
|
||||
PdusForwarded: 0,
|
||||
Fc03: 0,
|
||||
Fc04: 0,
|
||||
Fc06: 0,
|
||||
Fc16: 0,
|
||||
FcOther: 0,
|
||||
RewrittenSlots: 0,
|
||||
PartialBcdWarnings: 0,
|
||||
InvalidBcdWarnings: 0,
|
||||
BackendException01: 0,
|
||||
BackendException02: 0,
|
||||
BackendException03: 0,
|
||||
BackendException04: 0,
|
||||
BackendExceptionOther: 0,
|
||||
BytesUpstreamIn: 0,
|
||||
BytesUpstreamOut: 0,
|
||||
RecoveryAttempts: 0,
|
||||
LastBindError: null,
|
||||
LastRoundTripMs: 0.0,
|
||||
ConnectsSuccess: 0,
|
||||
ConnectsFailed: 0,
|
||||
InFlightCount: 0,
|
||||
MaxInFlight: 0,
|
||||
TxIdWraps: 0,
|
||||
BackendDisconnectCascades: 0,
|
||||
BackendQueueDepth: 0);
|
||||
|
||||
// Phase 08: ConnectsSuccess / ConnectsFailed are now tracked in ProxyCounters.
|
||||
long connectsSuccess = counters.ConnectsSuccess;
|
||||
long connectsFailed = counters.ConnectsFailed;
|
||||
|
||||
plcStatuses.Add(new PlcStatus(
|
||||
Name: plc.Name,
|
||||
Host: plc.Host,
|
||||
ListenPort: plc.ListenPort,
|
||||
Listener: new PlcListenerStatus(
|
||||
State: stateStr,
|
||||
LastBindError: snap?.LastBindError,
|
||||
RecoveryAttempts: snap?.RecoveryAttempts ?? 0),
|
||||
Clients: new PlcClientsStatus(
|
||||
Connected: clientSnapshots.Count,
|
||||
RemoteEndpoints: clientSnapshots),
|
||||
Pdus: new PlcPdusStatus(
|
||||
Forwarded: counters.PdusForwarded,
|
||||
ByFc: new FcCounts(counters.Fc03, counters.Fc04, counters.Fc06, counters.Fc16, counters.FcOther),
|
||||
RewrittenSlots: counters.RewrittenSlots,
|
||||
PartialBcdWarnings: counters.PartialBcdWarnings),
|
||||
Backend: new PlcBackendStatus(
|
||||
ConnectsSuccess: connectsSuccess,
|
||||
ConnectsFailed: connectsFailed,
|
||||
ExceptionsByCode: new ExceptionCounts(
|
||||
counters.BackendException01,
|
||||
counters.BackendException02,
|
||||
counters.BackendException03,
|
||||
counters.BackendException04),
|
||||
LastRoundTripMs: counters.LastRoundTripMs,
|
||||
InFlight: counters.InFlightCount,
|
||||
MaxInFlight: counters.MaxInFlight,
|
||||
TxIdWraps: counters.TxIdWraps,
|
||||
DisconnectCascades: counters.BackendDisconnectCascades,
|
||||
QueueDepth: counters.BackendQueueDepth),
|
||||
Bytes: new PlcBytesStatus(
|
||||
UpstreamIn: counters.BytesUpstreamIn,
|
||||
UpstreamOut: counters.BytesUpstreamOut)));
|
||||
}
|
||||
|
||||
// ── Service-wide fields ───────────────────────────────────────────────
|
||||
var service = new ServiceFields(
|
||||
UptimeSeconds: uptime,
|
||||
Version: _version.Version,
|
||||
ConfigLastReloadUtc: _serviceCounters.LastReloadUtc,
|
||||
ConfigReloadCount: _serviceCounters.ReloadAppliedCount,
|
||||
ConfigReloadRejectedCount: _serviceCounters.ReloadRejectedCount);
|
||||
|
||||
var listeners = new ListenersAggregate(
|
||||
Bound: boundCount,
|
||||
Configured: opts.Plcs.Count);
|
||||
|
||||
return new StatusResponse(service, listeners, plcStatuses);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user