docs(lmxproxy): add v2 rebuild design, 7-phase implementation plans, and execution prompt
Design doc covers architecture, v2 protocol (TypedValue/QualityCode), COM threading model, session lifecycle, subscription semantics, error model, and guardrails. Implementation plans are detailed enough for autonomous Claude Code execution. Verified all dev tooling on windev (Grpc.Tools, protobuf-net.Grpc, Polly v8, xUnit).
This commit is contained in:
210
lmxproxy/docs/plans/2026-03-21-lmxproxy-v2-rebuild-design.md
Normal file
210
lmxproxy/docs/plans/2026-03-21-lmxproxy-v2-rebuild-design.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# LmxProxy v2 Rebuild — Design Document
|
||||
|
||||
**Date**: 2026-03-21
|
||||
**Status**: Approved
|
||||
**Scope**: Complete rebuild of LmxProxy Host and Client with v2 protocol
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Rebuild the LmxProxy gRPC proxy service from scratch, implementing the v2 protocol (TypedValue + QualityCode) as defined in `docs/lmxproxy_updates.md`. The existing code in `src/` is retained as reference only. No backward compatibility with v1.
|
||||
|
||||
## 2. Key Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| gRPC server for Host | Grpc.Core (C-core) | Only option for .NET Framework 4.8 server-side |
|
||||
| Service hosting | Topshelf | Proven, already deployed, simple install/uninstall |
|
||||
| Protocol version | v2 only, clean break | Small controlled client count, no value in v1 compat |
|
||||
| Shared code between projects | None — fully independent | Different .NET runtimes (.NET Fx 4.8 vs .NET 10), wire compat is the contract |
|
||||
| Client retry library | Polly v8+ | Building fresh on .NET 10, modern API |
|
||||
| Testing strategy | Unit tests during implementation, integration tests after Client functional | Phased approach, real hardware validation on windev |
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Host (.NET Framework 4.8, x86)
|
||||
|
||||
```
|
||||
Program.cs (Topshelf entry point)
|
||||
└── LmxProxyService (lifecycle manager)
|
||||
├── Configuration (appsettings.json binding + validation)
|
||||
├── MxAccessClient (COM interop, STA dispatch thread)
|
||||
│ ├── Connection state machine
|
||||
│ ├── Read/Write with semaphore concurrency
|
||||
│ ├── Subscription storage for reconnect replay
|
||||
│ └── Auto-reconnect loop (5s interval)
|
||||
├── SessionManager (ConcurrentDictionary, 5-min inactivity scavenging)
|
||||
├── SubscriptionManager (per-client channels, shared MxAccess subscriptions)
|
||||
├── ApiKeyService (JSON file, FileSystemWatcher hot-reload)
|
||||
├── ScadaGrpcService (proto-generated, all 10 RPCs)
|
||||
│ └── ApiKeyInterceptor (x-api-key header enforcement)
|
||||
├── PerformanceMetrics (per-op tracking, p95, 60s log)
|
||||
├── HealthCheckService (basic + detailed with test tag)
|
||||
└── StatusWebServer (HTML dashboard, JSON status, health endpoint)
|
||||
```
|
||||
|
||||
### 3.2 Client (.NET 10, AnyCPU)
|
||||
|
||||
```
|
||||
ILmxProxyClient (public interface)
|
||||
└── LmxProxyClient (partial class)
|
||||
├── Connection (GrpcChannel, protobuf-net.Grpc, 30s keep-alive)
|
||||
├── Read/Write/Subscribe operations
|
||||
├── CodeFirstSubscription (IAsyncEnumerable streaming)
|
||||
├── ClientMetrics (p95/p99, 1000-sample buffer)
|
||||
└── Disposal (session disconnect, channel cleanup)
|
||||
|
||||
LmxProxyClientBuilder (fluent builder, Polly v8 resilience pipeline)
|
||||
ILmxProxyClientFactory + LmxProxyClientFactory (config-based creation)
|
||||
ServiceCollectionExtensions (DI registrations)
|
||||
StreamingExtensions (batched reads/writes, parallel processing)
|
||||
|
||||
Domain/
|
||||
├── ScadaContracts.cs (IScadaService + all DataContract messages)
|
||||
├── Quality.cs, QualityExtensions.cs
|
||||
├── Vtq.cs
|
||||
└── ConnectionState.cs
|
||||
```
|
||||
|
||||
### 3.3 Wire Compatibility
|
||||
|
||||
The `.proto` file is the single source of truth for the wire format. Host generates server stubs from it. Client implements code-first contracts (`[DataContract]`/`[ServiceContract]`) that mirror the proto exactly — same field numbers, names, nesting, and streaming shapes. Cross-stack serialization tests verify compatibility.
|
||||
|
||||
## 4. Protocol (v2)
|
||||
|
||||
### 4.1 TypedValue System
|
||||
|
||||
Protobuf `oneof` carrying native types:
|
||||
|
||||
| Case | Proto Type | .NET Type |
|
||||
|------|-----------|-----------|
|
||||
| bool_value | bool | bool |
|
||||
| int32_value | int32 | int |
|
||||
| int64_value | int64 | long |
|
||||
| float_value | float | float |
|
||||
| double_value | double | double |
|
||||
| string_value | string | string |
|
||||
| bytes_value | bytes | byte[] |
|
||||
| datetime_value | int64 (UTC Ticks) | DateTime |
|
||||
| array_value | ArrayValue | typed arrays |
|
||||
|
||||
Unset `oneof` = null. No string serialization heuristics.
|
||||
|
||||
### 4.2 COM Variant Coercion Table
|
||||
|
||||
| COM Variant Type | TypedValue Case | Notes |
|
||||
|-----------------|-----------------|-------|
|
||||
| VT_BOOL | bool_value | |
|
||||
| VT_I2 (short) | int32_value | Widened |
|
||||
| VT_I4 (int) | int32_value | |
|
||||
| VT_I8 (long) | int64_value | |
|
||||
| VT_UI2 (ushort) | int32_value | Widened |
|
||||
| VT_UI4 (uint) | int64_value | Widened to avoid sign issues |
|
||||
| VT_UI8 (ulong) | int64_value | Truncation risk logged if > long.MaxValue |
|
||||
| VT_R4 (float) | float_value | |
|
||||
| VT_R8 (double) | double_value | |
|
||||
| VT_BSTR (string) | string_value | |
|
||||
| VT_DATE (DateTime) | datetime_value | Converted to UTC Ticks |
|
||||
| VT_DECIMAL | double_value | Precision loss logged |
|
||||
| VT_CY (Currency) | double_value | |
|
||||
| VT_NULL, VT_EMPTY, DBNull | unset oneof | Represents null |
|
||||
| VT_ARRAY | array_value | Element type determines ArrayValue field |
|
||||
| VT_UNKNOWN | string_value | ToString() fallback, logged as warning |
|
||||
|
||||
### 4.3 QualityCode System
|
||||
|
||||
`status_code` (uint32, OPC UA-compatible) is canonical. `symbolic_name` is derived from a lookup table, never set independently.
|
||||
|
||||
Category derived from high bits:
|
||||
- `0x00xxxxxx` = Good
|
||||
- `0x40xxxxxx` = Uncertain
|
||||
- `0x80xxxxxx` = Bad
|
||||
|
||||
Domain `Quality` enum uses byte values for the low-order byte, with extension methods `IsGood()`, `IsBad()`, `IsUncertain()`.
|
||||
|
||||
### 4.4 Error Model
|
||||
|
||||
| Error Type | Mechanism | Examples |
|
||||
|-----------|-----------|----------|
|
||||
| Infrastructure | gRPC StatusCode | Unauthenticated (bad API key), PermissionDenied (ReadOnly write), InvalidArgument (bad session), Unavailable (MxAccess down) |
|
||||
| Business outcome | Payload `success`/`message` fields | Tag read failure, write type mismatch, batch partial failure, WriteBatchAndWait flag timeout |
|
||||
| Subscription | gRPC StatusCode on stream | Unauthenticated (invalid session), Internal (unexpected error) |
|
||||
|
||||
## 5. COM Threading Model
|
||||
|
||||
MxAccess is an STA COM component. All COM operations execute on a **dedicated STA thread** with a `BlockingCollection<Action>` dispatch queue:
|
||||
|
||||
- `MxAccessClient` creates a single STA thread at construction
|
||||
- All COM calls (connect, read, write, subscribe, disconnect) are dispatched to this thread via the queue
|
||||
- Callers await a `TaskCompletionSource<T>` that the STA thread completes after the COM call
|
||||
- The STA thread runs a message pump loop (`Application.Run` or manual `MSG` pump)
|
||||
- On disposal, a sentinel is enqueued and the thread joins with a 10-second timeout
|
||||
|
||||
This replaces the fragile `Task.Run` + `SemaphoreSlim` pattern in the reference code.
|
||||
|
||||
## 6. Session Lifecycle
|
||||
|
||||
- Sessions created on `Connect` with GUID "N" format (32-char hex)
|
||||
- Tracked in `ConcurrentDictionary<string, SessionInfo>`
|
||||
- **Inactivity scavenging**: sessions not accessed for 5 minutes are automatically terminated. Client keep-alive pings (30s) keep legitimate sessions alive.
|
||||
- On termination: subscriptions cleaned up, session removed from dictionary
|
||||
- All sessions lost on service restart (in-memory only)
|
||||
|
||||
## 7. Subscription Semantics
|
||||
|
||||
- **Shared MxAccess subscriptions**: first client to subscribe creates the underlying MxAccess subscription. Last to unsubscribe disposes it. Ref-counted.
|
||||
- **Sampling rate**: when multiple clients subscribe to the same tag with different `sampling_ms`, the fastest (lowest non-zero) rate is used for the MxAccess subscription. All clients receive updates at this rate.
|
||||
- **Per-client channels**: each client gets an independent `BoundedChannel<VtqMessage>` (capacity 1000, DropOldest). One slow consumer's drops do not affect other clients.
|
||||
- **MxAccess disconnect**: all subscribed clients receive a bad-quality notification for all their subscribed tags.
|
||||
- **Session termination**: all subscriptions for that session are cleaned up.
|
||||
|
||||
## 8. Authentication
|
||||
|
||||
- `x-api-key` gRPC metadata header is the authoritative authentication mechanism
|
||||
- `ConnectRequest.api_key` is accepted but the interceptor is the enforcement point
|
||||
- API keys loaded from JSON file with FileSystemWatcher hot-reload (1-second debounce)
|
||||
- Auto-generates default file with two random keys (ReadOnly + ReadWrite) if missing
|
||||
- Write-protected RPCs: Write, WriteBatch, WriteBatchAndWait
|
||||
|
||||
## 9. Phasing
|
||||
|
||||
| Phase | Scope | Depends On |
|
||||
|-------|-------|------------|
|
||||
| 1 | Protocol & Domain Types | — |
|
||||
| 2 | Host Core (MxAccessClient, SessionManager, SubscriptionManager) | Phase 1 |
|
||||
| 3 | Host gRPC Server, Security, Configuration, Service Hosting | Phase 2 |
|
||||
| 4 | Host Health, Metrics, Status Server | Phase 3 |
|
||||
| 5 | Client Core | Phase 1 |
|
||||
| 6 | Client Extras (Builder, Factory, DI, Streaming) | Phase 5 |
|
||||
| 7 | Integration Tests & Deployment | Phases 4 + 6 |
|
||||
|
||||
Phases 2-4 (Host) and 5-6 (Client) can proceed in parallel after Phase 1.
|
||||
|
||||
## 10. Guardrails
|
||||
|
||||
1. **Proto is the source of truth** — any wire format question is resolved by reading `scada.proto`, not the code-first contracts.
|
||||
2. **No v1 code in the new build** — reference only. Do not copy-paste and modify; write fresh.
|
||||
3. **Cross-stack tests in Phase 1** — Host proto serialize → Client code-first deserialize (and vice versa) before any business logic.
|
||||
4. **COM calls only on STA thread** — no `Task.Run` for COM operations. All go through the dispatch queue.
|
||||
5. **status_code is canonical for quality** — `symbolic_name` is always derived, never independently set.
|
||||
6. **Unit tests before integration** — every phase includes unit tests. Integration tests are Phase 7 only.
|
||||
7. **Each phase must compile and pass tests** before the next phase begins.
|
||||
8. **No string serialization heuristics** — v2 uses native TypedValue. No `double.TryParse` or `bool.TryParse` on values.
|
||||
|
||||
## 11. Resolved Conflicts
|
||||
|
||||
| Conflict | Resolution |
|
||||
|----------|-----------|
|
||||
| WriteBatchAndWait signature (MxAccessClient vs Protocol) | Follow Protocol spec: write items, poll flagTag for flagValue. IScadaClient interface matches protocol semantics. |
|
||||
| Builder default port 5050 vs Host 50051 | Standardize builder default to 50051 |
|
||||
| Auth in metadata vs payload | x-api-key header is authoritative; ConnectRequest.api_key accepted but interceptor enforces |
|
||||
|
||||
## 12. Reference Code
|
||||
|
||||
The existing code remains in `src/` as `src-reference/` for consultation:
|
||||
- `src-reference/ZB.MOM.WW.LmxProxy.Host/` — v1 Host implementation
|
||||
- `src-reference/ZB.MOM.WW.LmxProxy.Client/` — v1 Client implementation
|
||||
|
||||
Key reference files for COM interop patterns:
|
||||
- `Implementation/MxAccessClient.Connection.cs` — COM object lifecycle
|
||||
- `Implementation/MxAccessClient.EventHandlers.cs` — MxAccess callbacks
|
||||
- `Implementation/MxAccessClient.Subscription.cs` — Advise/Unadvise patterns
|
||||
2723
lmxproxy/docs/plans/phase-1-protocol-domain-types.md
Normal file
2723
lmxproxy/docs/plans/phase-1-protocol-domain-types.md
Normal file
File diff suppressed because it is too large
Load Diff
2067
lmxproxy/docs/plans/phase-2-host-core.md
Normal file
2067
lmxproxy/docs/plans/phase-2-host-core.md
Normal file
File diff suppressed because it is too large
Load Diff
1799
lmxproxy/docs/plans/phase-3-host-grpc-security-config.md
Normal file
1799
lmxproxy/docs/plans/phase-3-host-grpc-security-config.md
Normal file
File diff suppressed because it is too large
Load Diff
666
lmxproxy/docs/plans/phase-4-host-health-metrics.md
Normal file
666
lmxproxy/docs/plans/phase-4-host-health-metrics.md
Normal file
@@ -0,0 +1,666 @@
|
||||
# Phase 4: Host Health, Metrics & Status Server — Implementation Plan
|
||||
|
||||
**Date**: 2026-03-21
|
||||
**Prerequisites**: Phase 3 complete and passing (gRPC server, Security, Configuration, Service Hosting all functional)
|
||||
**Working Directory**: The lmxproxy repo is on windev at `C:\src\lmxproxy`
|
||||
|
||||
## Guardrails
|
||||
|
||||
1. **This is a v2 rebuild** — do not copy code from the v1 reference in `src-reference/`. Write fresh implementations guided by the design docs and the reference code's structure.
|
||||
2. **Host targets .NET Framework 4.8, x86** — all code must use C# 9.0 language features maximum (`LangVersion` is `9.0` in the csproj). No file-scoped namespaces, no `required` keyword, no collection expressions in Host code.
|
||||
3. **No new NuGet packages** — all required packages are already in the Host `.csproj` (`Microsoft.Extensions.Diagnostics.HealthChecks`, `Serilog`, `System.Threading.Channels`, `System.Text.Json` via framework).
|
||||
4. **Namespace**: `ZB.MOM.WW.LmxProxy.Host` with sub-namespaces matching folder structure (e.g., `ZB.MOM.WW.LmxProxy.Host.Health`, `ZB.MOM.WW.LmxProxy.Host.Metrics`, `ZB.MOM.WW.LmxProxy.Host.Status`).
|
||||
5. **All COM operations are on the STA thread** — health checks that read test tags must go through `MxAccessClient.ReadAsync()`, never directly touching COM objects.
|
||||
6. **Build must pass after each step**: `dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86`
|
||||
7. **Tests run on windev**: `dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests --platform x86`
|
||||
|
||||
## Step 1: Create PerformanceMetrics
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Host/Metrics/PerformanceMetrics.cs`
|
||||
|
||||
Create the `PerformanceMetrics` class in namespace `ZB.MOM.WW.LmxProxy.Host.Metrics`.
|
||||
|
||||
### 1.1 OperationMetrics (nested or separate class in same file)
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Metrics
|
||||
{
|
||||
public class OperationMetrics
|
||||
{
|
||||
private readonly List<double> _durations = new List<double>();
|
||||
private readonly object _lock = new object();
|
||||
private long _totalCount;
|
||||
private long _successCount;
|
||||
private double _totalMilliseconds;
|
||||
private double _minMilliseconds = double.MaxValue;
|
||||
private double _maxMilliseconds;
|
||||
|
||||
public void Record(TimeSpan duration, bool success) { ... }
|
||||
public MetricsStatistics GetStatistics() { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implementation details:
|
||||
- `Record(TimeSpan duration, bool success)`: Inside `lock (_lock)`, increment `_totalCount`, conditionally increment `_successCount`, add `duration.TotalMilliseconds` to `_durations` list, update `_totalMilliseconds`, `_minMilliseconds`, `_maxMilliseconds`. If `_durations.Count > 1000`, call `_durations.RemoveAt(0)` to maintain rolling buffer.
|
||||
- `GetStatistics()`: Inside `lock (_lock)`, return early with empty `MetricsStatistics` if `_totalCount == 0`. Otherwise sort `_durations`, compute p95 index as `(int)Math.Ceiling(sortedDurations.Count * 0.95) - 1`, clamp to `Math.Max(0, p95Index)`.
|
||||
|
||||
### 1.2 MetricsStatistics
|
||||
|
||||
```csharp
|
||||
public class MetricsStatistics
|
||||
{
|
||||
public long TotalCount { get; set; }
|
||||
public long SuccessCount { get; set; }
|
||||
public double SuccessRate { get; set; }
|
||||
public double AverageMilliseconds { get; set; }
|
||||
public double MinMilliseconds { get; set; }
|
||||
public double MaxMilliseconds { get; set; }
|
||||
public double Percentile95Milliseconds { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 ITimingScope interface and TimingScope implementation
|
||||
|
||||
```csharp
|
||||
public interface ITimingScope : IDisposable
|
||||
{
|
||||
void SetSuccess(bool success);
|
||||
}
|
||||
```
|
||||
|
||||
`TimingScope` is a private nested class inside `PerformanceMetrics`:
|
||||
- Constructor takes `PerformanceMetrics metrics, string operationName`, starts a `Stopwatch`.
|
||||
- `SetSuccess(bool success)` stores the flag (default `true`).
|
||||
- `Dispose()`: stops stopwatch, calls `_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success)`. Guard against double-dispose with `_disposed` flag.
|
||||
|
||||
### 1.4 PerformanceMetrics class
|
||||
|
||||
```csharp
|
||||
public class PerformanceMetrics : IDisposable
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
|
||||
private readonly ConcurrentDictionary<string, OperationMetrics> _metrics = new ConcurrentDictionary<string, OperationMetrics>();
|
||||
private readonly Timer _reportingTimer;
|
||||
private bool _disposed;
|
||||
|
||||
public PerformanceMetrics()
|
||||
{
|
||||
_reportingTimer = new Timer(ReportMetrics, null, TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
public void RecordOperation(string operationName, TimeSpan duration, bool success = true) { ... }
|
||||
public ITimingScope BeginOperation(string operationName) => new TimingScope(this, operationName);
|
||||
public OperationMetrics? GetMetrics(string operationName) { ... }
|
||||
public IReadOnlyDictionary<string, OperationMetrics> GetAllMetrics() { ... }
|
||||
public Dictionary<string, MetricsStatistics> GetStatistics() { ... }
|
||||
|
||||
private void ReportMetrics(object? state) { ... } // Log each operation's stats at Information level
|
||||
public void Dispose() { ... } // Dispose timer, call ReportMetrics one final time
|
||||
}
|
||||
```
|
||||
|
||||
`ReportMetrics` iterates `_metrics`, calls `GetStatistics()` on each, logs via Serilog structured logging with properties: `Operation`, `Count`, `SuccessRate`, `AverageMs`, `MinMs`, `MaxMs`, `P95Ms`.
|
||||
|
||||
### 1.5 Verify build
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86"
|
||||
```
|
||||
|
||||
## Step 2: Create HealthCheckService
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Host/Health/HealthCheckService.cs`
|
||||
|
||||
Namespace: `ZB.MOM.WW.LmxProxy.Host.Health`
|
||||
|
||||
### 2.1 Basic HealthCheckService
|
||||
|
||||
```csharp
|
||||
public class HealthCheckService : IHealthCheck
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<HealthCheckService>();
|
||||
private readonly IScadaClient _scadaClient;
|
||||
private readonly SubscriptionManager _subscriptionManager;
|
||||
private readonly PerformanceMetrics _performanceMetrics;
|
||||
|
||||
public HealthCheckService(
|
||||
IScadaClient scadaClient,
|
||||
SubscriptionManager subscriptionManager,
|
||||
PerformanceMetrics performanceMetrics) { ... }
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Dependencies imported:
|
||||
- `ZB.MOM.WW.LmxProxy.Host.Domain` for `IScadaClient`, `ConnectionState`
|
||||
- `ZB.MOM.WW.LmxProxy.Host.Services` for `SubscriptionManager` (if still in that namespace after Phase 2/3; adjust import to match actual location)
|
||||
- `ZB.MOM.WW.LmxProxy.Host.Metrics` for `PerformanceMetrics`
|
||||
- `Microsoft.Extensions.Diagnostics.HealthChecks` for `IHealthCheck`, `HealthCheckResult`, `HealthCheckContext`
|
||||
|
||||
`CheckHealthAsync` logic:
|
||||
1. Create `Dictionary<string, object> data`.
|
||||
2. Read `_scadaClient.IsConnected` and `_scadaClient.ConnectionState` into `data["scada_connected"]` and `data["scada_connection_state"]`.
|
||||
3. Get subscription stats via `_subscriptionManager.GetSubscriptionStats()` — store `TotalClients`, `TotalTags` in data.
|
||||
4. Iterate `_performanceMetrics.GetAllMetrics()` to compute `totalOperations` and `averageSuccessRate`.
|
||||
5. Store `total_operations` and `average_success_rate` in data.
|
||||
6. Decision tree:
|
||||
- If `!isConnected` → `HealthCheckResult.Unhealthy("SCADA client is not connected", data: data)`
|
||||
- If `averageSuccessRate < 0.5 && totalOperations > 100` → `HealthCheckResult.Degraded(...)`
|
||||
- If `subscriptionStats.TotalClients > 100` → `HealthCheckResult.Degraded(...)`
|
||||
- Otherwise → `HealthCheckResult.Healthy("LmxProxy is healthy", data)`
|
||||
7. Wrap everything in try/catch — on exception return `Unhealthy` with exception details.
|
||||
|
||||
### 2.2 DetailedHealthCheckService
|
||||
|
||||
In the same file or a separate file `src/ZB.MOM.WW.LmxProxy.Host/Health/DetailedHealthCheckService.cs`:
|
||||
|
||||
```csharp
|
||||
public class DetailedHealthCheckService : IHealthCheck
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<DetailedHealthCheckService>();
|
||||
private readonly IScadaClient _scadaClient;
|
||||
private readonly string _testTagAddress;
|
||||
|
||||
public DetailedHealthCheckService(IScadaClient scadaClient, string testTagAddress = "System.Heartbeat") { ... }
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
`CheckHealthAsync` logic:
|
||||
1. If `!_scadaClient.IsConnected` → return `Unhealthy`.
|
||||
2. Try `Vtq vtq = await _scadaClient.ReadAsync(_testTagAddress, cancellationToken)`.
|
||||
3. If `vtq.Quality != Quality.Good` → return `Degraded` with quality info.
|
||||
4. If `DateTime.UtcNow - vtq.Timestamp > TimeSpan.FromMinutes(5)` → return `Degraded` (stale data).
|
||||
5. Otherwise → `Healthy`.
|
||||
6. Catch read exceptions → return `Degraded("Could not read test tag")`.
|
||||
7. Catch all exceptions → return `Unhealthy`.
|
||||
|
||||
### 2.3 Verify build
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86"
|
||||
```
|
||||
|
||||
## Step 3: Create StatusReportService
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Host/Status/StatusReportService.cs`
|
||||
|
||||
Namespace: `ZB.MOM.WW.LmxProxy.Host.Status`
|
||||
|
||||
### 3.1 Data model classes
|
||||
|
||||
Define in the same file (or a separate `StatusModels.cs` in the same folder):
|
||||
|
||||
```csharp
|
||||
public class StatusData
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string ServiceName { get; set; } = "";
|
||||
public string Version { get; set; } = "";
|
||||
public ConnectionStatus Connection { get; set; } = new ConnectionStatus();
|
||||
public SubscriptionStatus Subscriptions { get; set; } = new SubscriptionStatus();
|
||||
public PerformanceStatus Performance { get; set; } = new PerformanceStatus();
|
||||
public HealthInfo Health { get; set; } = new HealthInfo();
|
||||
public HealthInfo? DetailedHealth { get; set; }
|
||||
}
|
||||
|
||||
public class ConnectionStatus
|
||||
{
|
||||
public bool IsConnected { get; set; }
|
||||
public string State { get; set; } = "";
|
||||
public string NodeName { get; set; } = "";
|
||||
public string GalaxyName { get; set; } = "";
|
||||
}
|
||||
|
||||
public class SubscriptionStatus
|
||||
{
|
||||
public int TotalClients { get; set; }
|
||||
public int TotalTags { get; set; }
|
||||
public int ActiveSubscriptions { get; set; }
|
||||
}
|
||||
|
||||
public class PerformanceStatus
|
||||
{
|
||||
public long TotalOperations { get; set; }
|
||||
public double AverageSuccessRate { get; set; }
|
||||
public Dictionary<string, OperationStatus> Operations { get; set; } = new Dictionary<string, OperationStatus>();
|
||||
}
|
||||
|
||||
public class OperationStatus
|
||||
{
|
||||
public long TotalCount { get; set; }
|
||||
public double SuccessRate { get; set; }
|
||||
public double AverageMilliseconds { get; set; }
|
||||
public double MinMilliseconds { get; set; }
|
||||
public double MaxMilliseconds { get; set; }
|
||||
public double Percentile95Milliseconds { get; set; }
|
||||
}
|
||||
|
||||
public class HealthInfo
|
||||
{
|
||||
public string Status { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public Dictionary<string, string> Data { get; set; } = new Dictionary<string, string>();
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 StatusReportService
|
||||
|
||||
```csharp
|
||||
public class StatusReportService
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<StatusReportService>();
|
||||
private readonly IScadaClient _scadaClient;
|
||||
private readonly SubscriptionManager _subscriptionManager;
|
||||
private readonly PerformanceMetrics _performanceMetrics;
|
||||
private readonly HealthCheckService _healthCheckService;
|
||||
private readonly DetailedHealthCheckService? _detailedHealthCheckService;
|
||||
|
||||
public StatusReportService(
|
||||
IScadaClient scadaClient,
|
||||
SubscriptionManager subscriptionManager,
|
||||
PerformanceMetrics performanceMetrics,
|
||||
HealthCheckService healthCheckService,
|
||||
DetailedHealthCheckService? detailedHealthCheckService = null) { ... }
|
||||
|
||||
public async Task<string> GenerateHtmlReportAsync() { ... }
|
||||
public async Task<string> GenerateJsonReportAsync() { ... }
|
||||
public async Task<bool> IsHealthyAsync() { ... }
|
||||
private async Task<StatusData> CollectStatusDataAsync() { ... }
|
||||
private static string GenerateHtmlFromStatusData(StatusData statusData) { ... }
|
||||
private static string GenerateErrorHtml(Exception ex) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
`CollectStatusDataAsync`:
|
||||
- Populate `StatusData.Timestamp = DateTime.UtcNow`, `ServiceName = "ZB.MOM.WW.LmxProxy.Host"`, `Version` from `Assembly.GetExecutingAssembly().GetName().Version`.
|
||||
- Connection info from `_scadaClient.IsConnected`, `_scadaClient.ConnectionState`.
|
||||
- Subscription stats from `_subscriptionManager.GetSubscriptionStats()`.
|
||||
- Performance stats from `_performanceMetrics.GetStatistics()` — include P95 in the `OperationStatus`.
|
||||
- Health from `_healthCheckService.CheckHealthAsync(new HealthCheckContext())`.
|
||||
- Detailed health from `_detailedHealthCheckService?.CheckHealthAsync(new HealthCheckContext())` if not null.
|
||||
|
||||
`GenerateJsonReportAsync`:
|
||||
- Use `System.Text.Json.JsonSerializer.Serialize(statusData, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase })`.
|
||||
|
||||
`GenerateHtmlFromStatusData`:
|
||||
- Use `StringBuilder` to generate self-contained HTML.
|
||||
- Include inline CSS (Bootstrap-like grid, status cards with color-coded left borders).
|
||||
- Color coding: green (#28a745) for Healthy/Connected, yellow (#ffc107) for Degraded, red (#dc3545) for Unhealthy/Disconnected.
|
||||
- Operations table with columns: Operation, Count, Success Rate, Avg (ms), Min (ms), Max (ms), P95 (ms).
|
||||
- `<meta http-equiv="refresh" content="30">` for auto-refresh.
|
||||
- Last updated timestamp at the bottom.
|
||||
|
||||
`IsHealthyAsync`:
|
||||
- Run basic health check, return `result.Status == HealthStatus.Healthy`.
|
||||
|
||||
### 3.3 Verify build
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86"
|
||||
```
|
||||
|
||||
## Step 4: Create StatusWebServer
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Host/Status/StatusWebServer.cs`
|
||||
|
||||
Namespace: `ZB.MOM.WW.LmxProxy.Host.Status`
|
||||
|
||||
```csharp
|
||||
public class StatusWebServer : IDisposable
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<StatusWebServer>();
|
||||
private readonly WebServerConfiguration _configuration;
|
||||
private readonly StatusReportService _statusReportService;
|
||||
private HttpListener? _httpListener;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private Task? _listenerTask;
|
||||
private bool _disposed;
|
||||
|
||||
public StatusWebServer(WebServerConfiguration configuration, StatusReportService statusReportService) { ... }
|
||||
|
||||
public bool Start() { ... }
|
||||
public bool Stop() { ... }
|
||||
public void Dispose() { ... }
|
||||
|
||||
private async Task HandleRequestsAsync(CancellationToken cancellationToken) { ... }
|
||||
private async Task HandleRequestAsync(HttpListenerContext context) { ... }
|
||||
private async Task HandleStatusPageAsync(HttpListenerResponse response) { ... }
|
||||
private async Task HandleStatusApiAsync(HttpListenerResponse response) { ... }
|
||||
private async Task HandleHealthApiAsync(HttpListenerResponse response) { ... }
|
||||
private static async Task WriteResponseAsync(HttpListenerResponse response, string content, string contentType) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.1 Start()
|
||||
|
||||
1. If `!_configuration.Enabled`, log info and return `true`.
|
||||
2. Create `HttpListener`, add prefix `_configuration.Prefix ?? $"http://+:{_configuration.Port}/"` (ensure trailing `/`).
|
||||
3. Call `_httpListener.Start()`.
|
||||
4. Create `_cancellationTokenSource = new CancellationTokenSource()`.
|
||||
5. Start `_listenerTask = Task.Run(() => HandleRequestsAsync(_cancellationTokenSource.Token))`.
|
||||
6. On exception, log error and return `false`.
|
||||
|
||||
### 4.2 Stop()
|
||||
|
||||
1. If not enabled or listener is null, return `true`.
|
||||
2. Cancel `_cancellationTokenSource`.
|
||||
3. Wait for `_listenerTask` with 5-second timeout.
|
||||
4. Stop and close `_httpListener`.
|
||||
|
||||
### 4.3 HandleRequestsAsync
|
||||
|
||||
- Loop while not cancelled and listener is listening.
|
||||
- `await _httpListener.GetContextAsync()` — on success, spawn `Task.Run` to handle.
|
||||
- Catch `ObjectDisposedException` and `HttpListenerException(995)` as expected shutdown signals.
|
||||
- On other errors, log and delay 1 second before continuing.
|
||||
|
||||
### 4.4 HandleRequestAsync routing
|
||||
|
||||
| Path (lowered) | Handler |
|
||||
|---|---|
|
||||
| `/` | `HandleStatusPageAsync` — calls `_statusReportService.GenerateHtmlReportAsync()`, content type `text/html; charset=utf-8` |
|
||||
| `/api/status` | `HandleStatusApiAsync` — calls `_statusReportService.GenerateJsonReportAsync()`, content type `application/json; charset=utf-8` |
|
||||
| `/api/health` | `HandleHealthApiAsync` — calls `_statusReportService.IsHealthyAsync()`, returns `"OK"` (200) or `"UNHEALTHY"` (503) as `text/plain` |
|
||||
| Non-GET method | Return 405 Method Not Allowed |
|
||||
| Unknown path | Return 404 Not Found |
|
||||
| Exception | Return 500 Internal Server Error |
|
||||
|
||||
### 4.5 WriteResponseAsync
|
||||
|
||||
- Set `Content-Type`, add `Cache-Control: no-cache, no-store, must-revalidate`, `Pragma: no-cache`, `Expires: 0`.
|
||||
- Convert content to UTF-8 bytes, set `ContentLength64`, write to `response.OutputStream`.
|
||||
|
||||
### 4.6 Dispose
|
||||
|
||||
- Guard with `_disposed` flag. Call `Stop()`. Dispose `_cancellationTokenSource` and close `_httpListener`.
|
||||
|
||||
### 4.7 Verify build
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86"
|
||||
```
|
||||
|
||||
## Step 5: Wire into LmxProxyService
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Host/LmxProxyService.cs`
|
||||
|
||||
This file already exists. Modify the `Start()` method to create and wire the new components. The v2 rebuild should create these fresh, but the wiring pattern follows the same order as the reference.
|
||||
|
||||
### 5.1 Add using directives
|
||||
|
||||
```csharp
|
||||
using ZB.MOM.WW.LmxProxy.Host.Health;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Status;
|
||||
```
|
||||
|
||||
### 5.2 Add fields
|
||||
|
||||
```csharp
|
||||
private PerformanceMetrics? _performanceMetrics;
|
||||
private HealthCheckService? _healthCheckService;
|
||||
private DetailedHealthCheckService? _detailedHealthCheckService;
|
||||
private StatusReportService? _statusReportService;
|
||||
private StatusWebServer? _statusWebServer;
|
||||
```
|
||||
|
||||
### 5.3 In Start(), after SessionManager and SubscriptionManager creation
|
||||
|
||||
```csharp
|
||||
// Create performance metrics
|
||||
_performanceMetrics = new PerformanceMetrics();
|
||||
|
||||
// Create health check services
|
||||
_healthCheckService = new HealthCheckService(_scadaClient, _subscriptionManager, _performanceMetrics);
|
||||
_detailedHealthCheckService = new DetailedHealthCheckService(_scadaClient);
|
||||
|
||||
// Create status report service
|
||||
_statusReportService = new StatusReportService(
|
||||
_scadaClient, _subscriptionManager, _performanceMetrics,
|
||||
_healthCheckService, _detailedHealthCheckService);
|
||||
|
||||
// Start status web server
|
||||
_statusWebServer = new StatusWebServer(_configuration.WebServer, _statusReportService);
|
||||
if (!_statusWebServer.Start())
|
||||
{
|
||||
Logger.Warning("Status web server failed to start — continuing without it");
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 In Stop(), before gRPC server shutdown
|
||||
|
||||
```csharp
|
||||
// Stop status web server
|
||||
_statusWebServer?.Stop();
|
||||
|
||||
// Dispose performance metrics
|
||||
_performanceMetrics?.Dispose();
|
||||
```
|
||||
|
||||
### 5.5 Pass _performanceMetrics to ScadaGrpcService constructor
|
||||
|
||||
Ensure `ScadaGrpcService` receives `_performanceMetrics` so it can record timings on each RPC call. The gRPC service should call `_performanceMetrics.BeginOperation("Read")` (etc.) and dispose the timing scope at the end of each RPC handler.
|
||||
|
||||
### 5.6 Verify build
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Host --platform x86"
|
||||
```
|
||||
|
||||
## Step 6: Unit Tests
|
||||
|
||||
**Project**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/`
|
||||
|
||||
If this project does not exist yet, create it:
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet new xunit -n ZB.MOM.WW.LmxProxy.Host.Tests -o tests/ZB.MOM.WW.LmxProxy.Host.Tests --framework net48"
|
||||
```
|
||||
|
||||
**Csproj adjustments** for `tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj`:
|
||||
- `<TargetFramework>net48</TargetFramework>`
|
||||
- `<PlatformTarget>x86</PlatformTarget>`
|
||||
- `<LangVersion>9.0</LangVersion>`
|
||||
- Add `<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Host\ZB.MOM.WW.LmxProxy.Host.csproj" />`
|
||||
- Add `<PackageReference Include="xunit" Version="2.9.3" />`
|
||||
- Add `<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />`
|
||||
- Add `<PackageReference Include="NSubstitute" Version="5.3.0" />` (for mocking IScadaClient)
|
||||
- Add `<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />`
|
||||
|
||||
**Also add to solution** in `ZB.MOM.WW.LmxProxy.slnx`:
|
||||
```xml
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj" />
|
||||
</Folder>
|
||||
```
|
||||
|
||||
### 6.1 PerformanceMetrics Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Metrics/PerformanceMetricsTests.cs`
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Metrics
|
||||
{
|
||||
public class PerformanceMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordOperation_TracksCountAndDuration()
|
||||
// Record 5 operations, verify GetStatistics returns TotalCount=5
|
||||
|
||||
[Fact]
|
||||
public void RecordOperation_TracksSuccessAndFailure()
|
||||
// Record 3 success + 2 failure, verify SuccessRate == 0.6
|
||||
|
||||
[Fact]
|
||||
public void GetStatistics_CalculatesP95Correctly()
|
||||
// Record 100 operations with known durations (1ms through 100ms)
|
||||
// Verify P95 is approximately 95ms
|
||||
|
||||
[Fact]
|
||||
public void RollingBuffer_CapsAt1000Samples()
|
||||
// Record 1500 operations, verify _durations list doesn't exceed 1000
|
||||
// (test via GetStatistics behavior — TotalCount is 1500 but percentile computed from 1000)
|
||||
|
||||
[Fact]
|
||||
public void BeginOperation_RecordsDurationOnDispose()
|
||||
// Use BeginOperation, await Task.Delay(50), dispose scope
|
||||
// Verify recorded duration >= 50ms
|
||||
|
||||
[Fact]
|
||||
public void TimingScope_DefaultsToSuccess()
|
||||
// BeginOperation + dispose without calling SetSuccess
|
||||
// Verify SuccessCount == 1
|
||||
|
||||
[Fact]
|
||||
public void TimingScope_RespectsSetSuccessFalse()
|
||||
// BeginOperation, SetSuccess(false), dispose
|
||||
// Verify SuccessCount == 0, TotalCount == 1
|
||||
|
||||
[Fact]
|
||||
public void GetMetrics_ReturnsNullForUnknownOperation()
|
||||
|
||||
[Fact]
|
||||
public void GetAllMetrics_ReturnsAllTrackedOperations()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 HealthCheckService Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Health/HealthCheckServiceTests.cs`
|
||||
|
||||
Use NSubstitute to mock `IScadaClient`. Create a real `PerformanceMetrics` instance and a real or mock `SubscriptionManager` (depends on Phase 2/3 implementation — if `SubscriptionManager` has an interface, mock it; if not, use the `GetSubscriptionStats()` approach with a concrete instance).
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Health
|
||||
{
|
||||
public class HealthCheckServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnsHealthy_WhenConnectedAndNormalMetrics()
|
||||
// Mock: IsConnected=true, ConnectionState=Connected
|
||||
// SubscriptionStats: TotalClients=5, TotalTags=10
|
||||
// PerformanceMetrics: record some successes
|
||||
// Assert: HealthStatus.Healthy
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsUnhealthy_WhenNotConnected()
|
||||
// Mock: IsConnected=false
|
||||
// Assert: HealthStatus.Unhealthy, description contains "not connected"
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsDegraded_WhenSuccessRateBelow50Percent()
|
||||
// Mock: IsConnected=true
|
||||
// Record 200 operations with 40% success rate
|
||||
// Assert: HealthStatus.Degraded
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsDegraded_WhenClientCountOver100()
|
||||
// Mock: IsConnected=true, SubscriptionStats.TotalClients=150
|
||||
// Assert: HealthStatus.Degraded
|
||||
|
||||
[Fact]
|
||||
public async Task DoesNotFlagLowSuccessRate_Under100Operations()
|
||||
// Record 50 operations with 0% success rate
|
||||
// Assert: still Healthy (threshold is > 100 total ops)
|
||||
}
|
||||
|
||||
public class DetailedHealthCheckServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnsUnhealthy_WhenNotConnected()
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsHealthy_WhenTestTagGoodAndRecent()
|
||||
// Mock ReadAsync returns Good quality with recent timestamp
|
||||
// Assert: Healthy
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsDegraded_WhenTestTagQualityNotGood()
|
||||
// Mock ReadAsync returns Uncertain quality
|
||||
// Assert: Degraded
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsDegraded_WhenTestTagTimestampStale()
|
||||
// Mock ReadAsync returns Good quality but timestamp 10 minutes ago
|
||||
// Assert: Degraded
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnsDegraded_WhenTestTagReadThrows()
|
||||
// Mock ReadAsync throws exception
|
||||
// Assert: Degraded
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 StatusReportService Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Host.Tests/Status/StatusReportServiceTests.cs`
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Tests.Status
|
||||
{
|
||||
public class StatusReportServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GenerateJsonReportAsync_ReturnsCamelCaseJson()
|
||||
// Verify JSON contains "serviceName", "connection", "isConnected" (camelCase)
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateHtmlReportAsync_ContainsAutoRefresh()
|
||||
// Verify HTML contains <meta http-equiv="refresh" content="30">
|
||||
|
||||
[Fact]
|
||||
public async Task IsHealthyAsync_ReturnsTrueWhenHealthy()
|
||||
|
||||
[Fact]
|
||||
public async Task IsHealthyAsync_ReturnsFalseWhenUnhealthy()
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateJsonReportAsync_IncludesPerformanceMetrics()
|
||||
// Record some operations, verify JSON includes operation names and stats
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 Run tests
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests --platform x86 --verbosity normal"
|
||||
```
|
||||
|
||||
## Step 7: Build Verification
|
||||
|
||||
Run full solution build and tests:
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx && dotnet test --verbosity normal"
|
||||
```
|
||||
|
||||
If the test project is .NET 4.8 x86, you may need:
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx --platform x86 && dotnet test tests/ZB.MOM.WW.LmxProxy.Host.Tests --platform x86"
|
||||
```
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `PerformanceMetrics` class with `OperationMetrics`, `MetricsStatistics`, `ITimingScope` in `src/ZB.MOM.WW.LmxProxy.Host/Metrics/`
|
||||
- [ ] `HealthCheckService` and `DetailedHealthCheckService` in `src/ZB.MOM.WW.LmxProxy.Host/Health/`
|
||||
- [ ] `StatusReportService` with data model classes in `src/ZB.MOM.WW.LmxProxy.Host/Status/`
|
||||
- [ ] `StatusWebServer` with HTML dashboard, JSON status, and health endpoints in `src/ZB.MOM.WW.LmxProxy.Host/Status/`
|
||||
- [ ] All components wired into `LmxProxyService.Start()` / `Stop()`
|
||||
- [ ] `ScadaGrpcService` uses `PerformanceMetrics.BeginOperation()` for Read, ReadBatch, Write, WriteBatch RPCs
|
||||
- [ ] Unit tests for PerformanceMetrics (recording, percentile, rolling buffer, timing scope)
|
||||
- [ ] Unit tests for HealthCheckService (healthy, unhealthy, degraded transitions)
|
||||
- [ ] Unit tests for DetailedHealthCheckService (connected, quality, staleness)
|
||||
- [ ] Unit tests for StatusReportService (JSON format, HTML format, health aggregation)
|
||||
- [ ] Solution builds without errors: `dotnet build ZB.MOM.WW.LmxProxy.slnx`
|
||||
- [ ] All tests pass: `dotnet test`
|
||||
852
lmxproxy/docs/plans/phase-5-client-core.md
Normal file
852
lmxproxy/docs/plans/phase-5-client-core.md
Normal file
@@ -0,0 +1,852 @@
|
||||
# Phase 5: Client Core — Implementation Plan
|
||||
|
||||
**Date**: 2026-03-21
|
||||
**Prerequisites**: Phase 1 complete and passing (Protocol & Domain Types — `ScadaContracts.cs` with v2 `TypedValue`/`QualityCode` messages, `Quality.cs`, `QualityExtensions.cs`, `Vtq.cs`, `ConnectionState.cs` all exist and cross-stack serialization tests pass)
|
||||
**Working Directory**: The lmxproxy repo is on windev at `C:\src\lmxproxy`
|
||||
|
||||
## Guardrails
|
||||
|
||||
1. **Client targets .NET 10, AnyCPU** — use latest C# features freely. The csproj `<TargetFramework>` is `net10.0`, `<LangVersion>latest</LangVersion>`.
|
||||
2. **Code-first gRPC only** — the Client uses `protobuf-net.Grpc` with `[ServiceContract]`/`[DataContract]` attributes. Never reference proto files or `Grpc.Tools`.
|
||||
3. **No string serialization heuristics** — v2 uses native `TypedValue`. Do not write `double.TryParse`, `bool.TryParse`, or any string-to-value parsing on tag values.
|
||||
4. **`status_code` is canonical for quality** — `symbolic_name` is derived. Never set `symbolic_name` independently.
|
||||
5. **Polly v8 API** — the Client csproj already has `<PackageReference Include="Polly" Version="8.5.2" />`. Use the v8 `ResiliencePipeline` API, not the legacy v7 `IAsyncPolicy` API.
|
||||
6. **No new NuGet packages** — all needed packages are already in `src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj`.
|
||||
7. **Build command**: `dotnet build src/ZB.MOM.WW.LmxProxy.Client`
|
||||
8. **Test command**: `dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests`
|
||||
9. **Namespace root**: `ZB.MOM.WW.LmxProxy.Client`
|
||||
|
||||
## Step 1: ClientTlsConfiguration
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/ClientTlsConfiguration.cs`
|
||||
|
||||
This file already exists with the correct shape. Verify it has all these properties (from Component-Client.md):
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
public class ClientTlsConfiguration
|
||||
{
|
||||
public bool UseTls { get; set; } = false;
|
||||
public string? ClientCertificatePath { get; set; }
|
||||
public string? ClientKeyPath { get; set; }
|
||||
public string? ServerCaCertificatePath { get; set; }
|
||||
public string? ServerNameOverride { get; set; }
|
||||
public bool ValidateServerCertificate { get; set; } = true;
|
||||
public bool AllowSelfSignedCertificates { get; set; } = false;
|
||||
public bool IgnoreAllCertificateErrors { get; set; } = false;
|
||||
}
|
||||
```
|
||||
|
||||
If it matches, no changes needed. If any properties are missing, add them.
|
||||
|
||||
## Step 2: Security/GrpcChannelFactory
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/Security/GrpcChannelFactory.cs`
|
||||
|
||||
This file already exists. Verify the implementation covers:
|
||||
|
||||
1. `CreateChannel(Uri address, ClientTlsConfiguration? tlsConfiguration, ILogger logger)` — returns `GrpcChannel`.
|
||||
2. Creates `SocketsHttpHandler` with `EnableMultipleHttp2Connections = true`.
|
||||
3. For TLS: sets `SslProtocols = Tls12 | Tls13`, configures `ServerNameOverride` as `TargetHost`, loads client certificate from PEM files for mTLS.
|
||||
4. Certificate validation callback handles: `IgnoreAllCertificateErrors`, `!ValidateServerCertificate`, custom CA trust store via `ServerCaCertificatePath`, `AllowSelfSignedCertificates`.
|
||||
5. Static constructor sets `System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport = true` for non-TLS.
|
||||
|
||||
The existing implementation matches. No changes expected unless Phase 1 introduced breaking changes.
|
||||
|
||||
## Step 3: ILmxProxyClient Interface
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClient.cs`
|
||||
|
||||
Rewrite for v2 protocol. The key changes from v1:
|
||||
- `WriteAsync` and `WriteBatchAsync` accept `TypedValue` instead of `object`
|
||||
- `SubscribeAsync` has an `onStreamError` callback parameter
|
||||
- `CheckApiKeyAsync` is added
|
||||
- Return types use v2 domain `Vtq` (which wraps `TypedValue` + `QualityCode`)
|
||||
|
||||
```csharp
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for LmxProxy client operations.
|
||||
/// </summary>
|
||||
public interface ILmxProxyClient : IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Gets or sets the default timeout for operations (range: 1s to 10min).</summary>
|
||||
TimeSpan DefaultTimeout { get; set; }
|
||||
|
||||
/// <summary>Connects to the LmxProxy service and establishes a session.</summary>
|
||||
Task ConnectAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Disconnects from the LmxProxy service.</summary>
|
||||
Task DisconnectAsync();
|
||||
|
||||
/// <summary>Returns true if the client has an active session.</summary>
|
||||
Task<bool> IsConnectedAsync();
|
||||
|
||||
/// <summary>Reads a single tag value.</summary>
|
||||
Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Reads multiple tag values in a single batch.</summary>
|
||||
Task<IDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Writes a single tag value (native TypedValue — no string heuristics).</summary>
|
||||
Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Writes multiple tag values in a single batch.</summary>
|
||||
Task WriteBatchAsync(IDictionary<string, TypedValue> values, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a batch of values, then polls a flag tag until it matches or timeout expires.
|
||||
/// Returns (writeResults, flagReached, elapsedMs).
|
||||
/// </summary>
|
||||
Task<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(
|
||||
IDictionary<string, TypedValue> values,
|
||||
string flagTag,
|
||||
TypedValue flagValue,
|
||||
int timeoutMs = 5000,
|
||||
int pollIntervalMs = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Subscribes to tag updates with value and error callbacks.</summary>
|
||||
Task<ISubscription> SubscribeAsync(
|
||||
IEnumerable<string> addresses,
|
||||
Action<string, Vtq> onUpdate,
|
||||
Action<Exception>? onStreamError = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Validates an API key and returns info.</summary>
|
||||
Task<ApiKeyInfo> CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Returns a snapshot of client-side metrics.</summary>
|
||||
Dictionary<string, object> GetMetrics();
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The `TypedValue` class referenced here is from `Domain/ScadaContracts.cs` — it should already have been updated in Phase 1 to use `[DataContract]` with the v2 oneof-style properties (e.g., `BoolValue`, `Int32Value`, `DoubleValue`, `StringValue`, `DatetimeValue`, etc., with a `ValueCase` enum or similar discriminator).
|
||||
|
||||
## Step 4: LmxProxyClient — Main File
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.cs`
|
||||
|
||||
This is a partial class. The main file contains the constructor, fields, properties, and the Read/Write/WriteBatch/WriteBatchAndWait/CheckApiKey methods.
|
||||
|
||||
### 4.1 Fields and Constructor
|
||||
|
||||
```csharp
|
||||
public partial class LmxProxyClient : ILmxProxyClient
|
||||
{
|
||||
private readonly ILogger<LmxProxyClient> _logger;
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly string? _apiKey;
|
||||
private readonly ClientTlsConfiguration? _tlsConfiguration;
|
||||
private readonly ClientMetrics _metrics = new();
|
||||
private readonly SemaphoreSlim _connectionLock = new(1, 1);
|
||||
private readonly List<ISubscription> _activeSubscriptions = [];
|
||||
private readonly Lock _subscriptionLock = new();
|
||||
|
||||
private GrpcChannel? _channel;
|
||||
private IScadaService? _client;
|
||||
private string _sessionId = string.Empty;
|
||||
private bool _disposed;
|
||||
private bool _isConnected;
|
||||
private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
|
||||
private ClientConfiguration? _configuration;
|
||||
private ResiliencePipeline? _resiliencePipeline; // Polly v8
|
||||
private Timer? _keepAliveTimer;
|
||||
private readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
// IsConnected computed property
|
||||
public bool IsConnected => !_disposed && _isConnected && !string.IsNullOrEmpty(_sessionId);
|
||||
|
||||
public LmxProxyClient(
|
||||
string host, int port, string? apiKey,
|
||||
ClientTlsConfiguration? tlsConfiguration,
|
||||
ILogger<LmxProxyClient>? logger = null)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_port = port;
|
||||
_apiKey = apiKey;
|
||||
_tlsConfiguration = tlsConfiguration;
|
||||
_logger = logger ?? NullLogger<LmxProxyClient>.Instance;
|
||||
}
|
||||
|
||||
internal void SetBuilderConfiguration(ClientConfiguration config)
|
||||
{
|
||||
_configuration = config;
|
||||
// Build Polly v8 ResiliencePipeline from config
|
||||
if (config.MaxRetryAttempts > 0)
|
||||
{
|
||||
_resiliencePipeline = new ResiliencePipelineBuilder()
|
||||
.AddRetry(new RetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = config.MaxRetryAttempts,
|
||||
Delay = config.RetryDelay,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
ShouldHandle = new PredicateBuilder()
|
||||
.Handle<RpcException>(ex =>
|
||||
ex.StatusCode == StatusCode.Unavailable ||
|
||||
ex.StatusCode == StatusCode.DeadlineExceeded ||
|
||||
ex.StatusCode == StatusCode.ResourceExhausted ||
|
||||
ex.StatusCode == StatusCode.Aborted),
|
||||
OnRetry = args =>
|
||||
{
|
||||
_logger.LogWarning("Retry {Attempt} after {Delay} for {Exception}",
|
||||
args.AttemptNumber, args.RetryDelay, args.Outcome.Exception?.Message);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 ReadAsync
|
||||
|
||||
```csharp
|
||||
public async Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
_metrics.IncrementOperationCount("Read");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var request = new ReadRequest { SessionId = _sessionId, Tag = address };
|
||||
ReadResponse response = await ExecuteWithRetry(
|
||||
() => _client!.ReadAsync(request).AsTask(), cancellationToken);
|
||||
if (!response.Success)
|
||||
throw new InvalidOperationException($"Read failed: {response.Message}");
|
||||
return ConvertVtqMessage(response.Vtq);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_metrics.IncrementErrorCount("Read");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_metrics.RecordLatency("Read", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 ReadBatchAsync
|
||||
|
||||
```csharp
|
||||
public async Task<IDictionary<string, Vtq>> ReadBatchAsync(
|
||||
IEnumerable<string> addresses, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
_metrics.IncrementOperationCount("ReadBatch");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var request = new ReadBatchRequest { SessionId = _sessionId, Tags = addresses.ToList() };
|
||||
ReadBatchResponse response = await ExecuteWithRetry(
|
||||
() => _client!.ReadBatchAsync(request).AsTask(), cancellationToken);
|
||||
var result = new Dictionary<string, Vtq>();
|
||||
foreach (var vtqMsg in response.Vtqs)
|
||||
{
|
||||
result[vtqMsg.Tag] = ConvertVtqMessage(vtqMsg);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_metrics.IncrementErrorCount("ReadBatch");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_metrics.RecordLatency("ReadBatch", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 WriteAsync
|
||||
|
||||
```csharp
|
||||
public async Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
_metrics.IncrementOperationCount("Write");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var request = new WriteRequest { SessionId = _sessionId, Tag = address, Value = value };
|
||||
WriteResponse response = await ExecuteWithRetry(
|
||||
() => _client!.WriteAsync(request).AsTask(), cancellationToken);
|
||||
if (!response.Success)
|
||||
throw new InvalidOperationException($"Write failed: {response.Message}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
_metrics.IncrementErrorCount("Write");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_metrics.RecordLatency("Write", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 WriteBatchAsync
|
||||
|
||||
```csharp
|
||||
public async Task WriteBatchAsync(IDictionary<string, TypedValue> values, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
_metrics.IncrementOperationCount("WriteBatch");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var request = new WriteBatchRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList()
|
||||
};
|
||||
WriteBatchResponse response = await ExecuteWithRetry(
|
||||
() => _client!.WriteBatchAsync(request).AsTask(), cancellationToken);
|
||||
if (!response.Success)
|
||||
throw new InvalidOperationException($"WriteBatch failed: {response.Message}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
_metrics.IncrementErrorCount("WriteBatch");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_metrics.RecordLatency("WriteBatch", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 WriteBatchAndWaitAsync
|
||||
|
||||
```csharp
|
||||
public async Task<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(
|
||||
IDictionary<string, TypedValue> values, string flagTag, TypedValue flagValue,
|
||||
int timeoutMs = 5000, int pollIntervalMs = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
var request = new WriteBatchAndWaitRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList(),
|
||||
FlagTag = flagTag,
|
||||
FlagValue = flagValue,
|
||||
TimeoutMs = timeoutMs,
|
||||
PollIntervalMs = pollIntervalMs
|
||||
};
|
||||
return await ExecuteWithRetry(
|
||||
() => _client!.WriteBatchAndWaitAsync(request).AsTask(), cancellationToken);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.7 CheckApiKeyAsync
|
||||
|
||||
```csharp
|
||||
public async Task<ApiKeyInfo> CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
var request = new CheckApiKeyRequest { ApiKey = apiKey };
|
||||
CheckApiKeyResponse response = await _client!.CheckApiKeyAsync(request);
|
||||
return new ApiKeyInfo { IsValid = response.IsValid, Description = response.Message };
|
||||
}
|
||||
```
|
||||
|
||||
### 4.8 ConvertVtqMessage helper
|
||||
|
||||
This converts the wire `VtqMessage` (v2 with `TypedValue` + `QualityCode`) to the domain `Vtq`:
|
||||
|
||||
```csharp
|
||||
private static Vtq ConvertVtqMessage(VtqMessage? msg)
|
||||
{
|
||||
if (msg is null)
|
||||
return new Vtq(null, DateTime.UtcNow, Quality.Bad);
|
||||
|
||||
object? value = ExtractTypedValue(msg.Value);
|
||||
DateTime timestamp = msg.TimestampUtcTicks > 0
|
||||
? new DateTime(msg.TimestampUtcTicks, DateTimeKind.Utc)
|
||||
: DateTime.UtcNow;
|
||||
Quality quality = QualityExtensions.FromStatusCode(msg.Quality?.StatusCode ?? 0x80000000u);
|
||||
return new Vtq(value, timestamp, quality);
|
||||
}
|
||||
|
||||
private static object? ExtractTypedValue(TypedValue? tv)
|
||||
{
|
||||
if (tv is null) return null;
|
||||
// Switch on whichever oneof-style property is set
|
||||
// The exact property names depend on the Phase 1 code-first contract design
|
||||
// e.g., tv.BoolValue, tv.Int32Value, tv.DoubleValue, tv.StringValue, etc.
|
||||
// Return the native .NET value directly — no string conversions
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The exact shape of `TypedValue` in code-first contracts depends on Phase 1's implementation. Phase 1 should have defined a discriminator pattern (e.g., `ValueCase` enum or nullable properties with a convention). Adapt `ExtractTypedValue` to whatever pattern was chosen. The key rule: **no string heuristics**.
|
||||
|
||||
### 4.9 ExecuteWithRetry helper
|
||||
|
||||
```csharp
|
||||
private async Task<T> ExecuteWithRetry<T>(Func<Task<T>> operation, CancellationToken ct)
|
||||
{
|
||||
if (_resiliencePipeline is not null)
|
||||
{
|
||||
return await _resiliencePipeline.ExecuteAsync(
|
||||
async token => await operation(), ct);
|
||||
}
|
||||
return await operation();
|
||||
}
|
||||
```
|
||||
|
||||
### 4.10 EnsureConnected, Dispose, DisposeAsync
|
||||
|
||||
```csharp
|
||||
private void EnsureConnected()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!IsConnected)
|
||||
throw new InvalidOperationException("Client is not connected. Call ConnectAsync first.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_keepAliveTimer?.Dispose();
|
||||
_channel?.Dispose();
|
||||
_connectionLock.Dispose();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
try { await DisconnectAsync(); } catch { /* swallow */ }
|
||||
Dispose();
|
||||
}
|
||||
```
|
||||
|
||||
### 4.11 IsConnectedAsync
|
||||
|
||||
```csharp
|
||||
public Task<bool> IsConnectedAsync() => Task.FromResult(IsConnected);
|
||||
```
|
||||
|
||||
### 4.12 GetMetrics
|
||||
|
||||
```csharp
|
||||
public Dictionary<string, object> GetMetrics() => _metrics.GetSnapshot();
|
||||
```
|
||||
|
||||
### 4.13 Verify build
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
|
||||
```
|
||||
|
||||
## Step 5: LmxProxyClient.Connection
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.Connection.cs`
|
||||
|
||||
Partial class containing `ConnectAsync`, `DisconnectAsync`, keep-alive, `MarkDisconnectedAsync`, `BuildEndpointUri`.
|
||||
|
||||
### 5.1 ConnectAsync
|
||||
|
||||
1. Acquire `_connectionLock`.
|
||||
2. Throw `ObjectDisposedException` if disposed.
|
||||
3. Return early if already connected.
|
||||
4. Build endpoint URI via `BuildEndpointUri()`.
|
||||
5. Create channel: `GrpcChannelFactory.CreateChannel(endpoint, _tlsConfiguration, _logger)`.
|
||||
6. Create code-first client: `channel.CreateGrpcService<IScadaService>()` (from `ProtoBuf.Grpc.Client`).
|
||||
7. Send `ConnectRequest` with `ClientId = $"ScadaBridge-{Guid.NewGuid():N}"` and `ApiKey = _apiKey ?? string.Empty`.
|
||||
8. If `!response.Success`, dispose channel and throw.
|
||||
9. Store channel, client, sessionId. Set `_isConnected = true`.
|
||||
10. Call `StartKeepAlive()`.
|
||||
11. On failure, reset all state and rethrow.
|
||||
12. Release lock in `finally`.
|
||||
|
||||
### 5.2 DisconnectAsync
|
||||
|
||||
1. Acquire `_connectionLock`.
|
||||
2. Stop keep-alive.
|
||||
3. If client and session exist, send `DisconnectRequest`. Swallow exceptions.
|
||||
4. Clear client, sessionId, isConnected. Dispose channel.
|
||||
5. Release lock.
|
||||
|
||||
### 5.3 Keep-alive timer
|
||||
|
||||
- `StartKeepAlive()`: creates `Timer` with `_keepAliveInterval` (30s) interval.
|
||||
- Timer callback: sends `GetConnectionStateRequest`. On failure: stops timer, calls `MarkDisconnectedAsync(ex)`.
|
||||
- `StopKeepAlive()`: disposes timer, nulls it.
|
||||
|
||||
### 5.4 MarkDisconnectedAsync
|
||||
|
||||
1. If disposed, return.
|
||||
2. Acquire `_connectionLock`, set `_isConnected = false`, clear client/sessionId, dispose channel. Release lock.
|
||||
3. Copy and clear `_activeSubscriptions` under `_subscriptionLock`.
|
||||
4. Dispose each subscription (swallow errors).
|
||||
5. Log warning with the exception.
|
||||
|
||||
### 5.5 BuildEndpointUri
|
||||
|
||||
```csharp
|
||||
private Uri BuildEndpointUri()
|
||||
{
|
||||
string scheme = _tlsConfiguration?.UseTls == true ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
|
||||
return new UriBuilder { Scheme = scheme, Host = _host, Port = _port }.Uri;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.6 Verify build
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
|
||||
```
|
||||
|
||||
## Step 6: LmxProxyClient.CodeFirstSubscription
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.CodeFirstSubscription.cs`
|
||||
|
||||
Nested class inside `LmxProxyClient` implementing `ISubscription`.
|
||||
|
||||
### 6.1 CodeFirstSubscription class
|
||||
|
||||
```csharp
|
||||
private class CodeFirstSubscription : ISubscription
|
||||
{
|
||||
private readonly IScadaService _client;
|
||||
private readonly string _sessionId;
|
||||
private readonly List<string> _tags;
|
||||
private readonly Action<string, Vtq> _onUpdate;
|
||||
private readonly Action<Exception>? _onStreamError;
|
||||
private readonly ILogger<LmxProxyClient> _logger;
|
||||
private readonly Action<ISubscription>? _onDispose;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _processingTask;
|
||||
private bool _disposed;
|
||||
private bool _streamErrorFired;
|
||||
```
|
||||
|
||||
Constructor takes all of these. `StartAsync` stores `_processingTask = ProcessUpdatesAsync(cancellationToken)`.
|
||||
|
||||
### 6.2 ProcessUpdatesAsync
|
||||
|
||||
```csharp
|
||||
private async Task ProcessUpdatesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = new SubscribeRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Tags = _tags,
|
||||
SamplingMs = 1000
|
||||
};
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token);
|
||||
|
||||
await foreach (VtqMessage vtqMsg in _client.SubscribeAsync(request, linkedCts.Token))
|
||||
{
|
||||
try
|
||||
{
|
||||
Vtq vtq = ConvertVtqMessage(vtqMsg); // static method from outer class
|
||||
_onUpdate(vtqMsg.Tag, vtq);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing subscription update for {Tag}", vtqMsg.Tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (_cts.IsCancellationRequested || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Subscription cancelled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in subscription processing");
|
||||
FireStreamError(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
_onDispose?.Invoke(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void FireStreamError(Exception ex)
|
||||
{
|
||||
if (_streamErrorFired) return;
|
||||
_streamErrorFired = true;
|
||||
try { _onStreamError?.Invoke(ex); }
|
||||
catch (Exception cbEx) { _logger.LogWarning(cbEx, "onStreamError callback threw"); }
|
||||
}
|
||||
```
|
||||
|
||||
**Key difference from v1**: The `ConvertVtqMessage` now handles `TypedValue` + `QualityCode` natively instead of parsing strings. Also, `_onStreamError` callback is invoked exactly once on stream termination (per Component-Client.md section 5.1).
|
||||
|
||||
### 6.3 DisposeAsync and Dispose
|
||||
|
||||
`DisposeAsync()`: Cancel CTS, await `_processingTask` (swallow errors), dispose CTS. 5-second timeout guard.
|
||||
|
||||
`Dispose()`: Calls `DisposeAsync()` synchronously with `Task.Wait(TimeSpan.FromSeconds(5))`.
|
||||
|
||||
### 6.4 Verify build
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
|
||||
```
|
||||
|
||||
## Step 7: LmxProxyClient.ClientMetrics
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ClientMetrics.cs`
|
||||
|
||||
Internal class. Already exists in v1 reference. Rewrite for v2 with p99 support.
|
||||
|
||||
```csharp
|
||||
internal class ClientMetrics
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, long> _operationCounts = new();
|
||||
private readonly ConcurrentDictionary<string, long> _errorCounts = new();
|
||||
private readonly ConcurrentDictionary<string, List<long>> _latencies = new();
|
||||
private readonly Lock _latencyLock = new();
|
||||
|
||||
public void IncrementOperationCount(string operation) { ... }
|
||||
public void IncrementErrorCount(string operation) { ... }
|
||||
public void RecordLatency(string operation, long milliseconds) { ... }
|
||||
public Dictionary<string, object> GetSnapshot() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
`RecordLatency`: Under `_latencyLock`, add to list. If count > 1000, `RemoveAt(0)`.
|
||||
|
||||
`GetSnapshot`: Returns dictionary with keys `{op}_count`, `{op}_errors`, `{op}_avg_latency_ms`, `{op}_p95_latency_ms`, `{op}_p99_latency_ms`.
|
||||
|
||||
`GetPercentile(List<long> values, int percentile)`: Sort, compute index as `(int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1`, clamp with `Math.Max(0, ...)`.
|
||||
|
||||
## Step 8: LmxProxyClient.ApiKeyInfo
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ApiKeyInfo.cs`
|
||||
|
||||
Simple DTO returned by `CheckApiKeyAsync`:
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
public partial class LmxProxyClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of an API key validation check.
|
||||
/// </summary>
|
||||
public class ApiKeyInfo
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public string? Role { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 9: LmxProxyClient.ISubscription
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClient.ISubscription.cs`
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
public partial class LmxProxyClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents an active tag subscription. Dispose to unsubscribe.
|
||||
/// </summary>
|
||||
public interface ISubscription : IDisposable
|
||||
{
|
||||
/// <summary>Asynchronous disposal with cancellation support.</summary>
|
||||
Task DisposeAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 10: Unit Tests
|
||||
|
||||
**Project**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/`
|
||||
|
||||
Create if not exists:
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet new xunit -n ZB.MOM.WW.LmxProxy.Client.Tests -o tests/ZB.MOM.WW.LmxProxy.Client.Tests --framework net10.0"
|
||||
```
|
||||
|
||||
**Csproj** for `tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj`:
|
||||
- `<TargetFramework>net10.0</TargetFramework>`
|
||||
- `<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Client\ZB.MOM.WW.LmxProxy.Client.csproj" />`
|
||||
- `<PackageReference Include="xunit" Version="2.9.3" />`
|
||||
- `<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />`
|
||||
- `<PackageReference Include="NSubstitute" Version="5.3.0" />`
|
||||
- `<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />`
|
||||
|
||||
**Add to solution** `ZB.MOM.WW.LmxProxy.slnx`:
|
||||
```xml
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj" />
|
||||
</Folder>
|
||||
```
|
||||
|
||||
### 10.1 Connection Lifecycle Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientConnectionTests.cs`
|
||||
|
||||
Mock `IScadaService` using NSubstitute.
|
||||
|
||||
```csharp
|
||||
public class LmxProxyClientConnectionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConnectAsync_EstablishesSessionAndStartsKeepAlive()
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectAsync_ThrowsWhenServerReturnsFailure()
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectAsync_SendsDisconnectAndClearsState()
|
||||
|
||||
[Fact]
|
||||
public async Task IsConnectedAsync_ReturnsFalseBeforeConnect()
|
||||
|
||||
[Fact]
|
||||
public async Task IsConnectedAsync_ReturnsTrueAfterConnect()
|
||||
|
||||
[Fact]
|
||||
public async Task KeepAliveFailure_MarksDisconnected()
|
||||
}
|
||||
```
|
||||
|
||||
Note: Testing the keep-alive requires either waiting 30s (too slow) or making the interval configurable for tests. Consider passing the interval as an internal constructor parameter or using a test-only subclass. Alternatively, test `MarkDisconnectedAsync` directly.
|
||||
|
||||
### 10.2 Read/Write Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientReadWriteTests.cs`
|
||||
|
||||
```csharp
|
||||
public class LmxProxyClientReadWriteTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadAsync_ReturnsVtqFromResponse()
|
||||
// Mock ReadAsync to return a VtqMessage with TypedValue.DoubleValue = 42.5
|
||||
// Verify returned Vtq.Value is 42.5 (double)
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_ThrowsOnFailureResponse()
|
||||
|
||||
[Fact]
|
||||
public async Task ReadBatchAsync_ReturnsDictionaryOfVtqs()
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_SendsTypedValueDirectly()
|
||||
// Verify the WriteRequest.Value is the TypedValue passed in, not a string
|
||||
|
||||
[Fact]
|
||||
public async Task WriteBatchAsync_SendsAllItems()
|
||||
|
||||
[Fact]
|
||||
public async Task WriteBatchAndWaitAsync_ReturnsResponse()
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 Subscription Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientSubscriptionTests.cs`
|
||||
|
||||
```csharp
|
||||
public class LmxProxyClientSubscriptionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_InvokesCallbackForEachUpdate()
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_InvokesStreamErrorOnFailure()
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_DisposeStopsProcessing()
|
||||
}
|
||||
```
|
||||
|
||||
### 10.4 TypedValue Conversion Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/TypedValueConversionTests.cs`
|
||||
|
||||
```csharp
|
||||
public class TypedValueConversionTests
|
||||
{
|
||||
[Fact] public void ConvertVtqMessage_ExtractsBoolValue()
|
||||
[Fact] public void ConvertVtqMessage_ExtractsInt32Value()
|
||||
[Fact] public void ConvertVtqMessage_ExtractsInt64Value()
|
||||
[Fact] public void ConvertVtqMessage_ExtractsFloatValue()
|
||||
[Fact] public void ConvertVtqMessage_ExtractsDoubleValue()
|
||||
[Fact] public void ConvertVtqMessage_ExtractsStringValue()
|
||||
[Fact] public void ConvertVtqMessage_ExtractsDateTimeValue()
|
||||
[Fact] public void ConvertVtqMessage_HandlesNullTypedValue()
|
||||
[Fact] public void ConvertVtqMessage_HandlesNullMessage()
|
||||
[Fact] public void ConvertVtqMessage_MapsQualityCodeCorrectly()
|
||||
[Fact] public void ConvertVtqMessage_GoodQualityCode()
|
||||
[Fact] public void ConvertVtqMessage_BadQualityCode()
|
||||
[Fact] public void ConvertVtqMessage_UncertainQualityCode()
|
||||
}
|
||||
```
|
||||
|
||||
### 10.5 Metrics Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/ClientMetricsTests.cs`
|
||||
|
||||
```csharp
|
||||
public class ClientMetricsTests
|
||||
{
|
||||
[Fact] public void IncrementOperationCount_Increments()
|
||||
[Fact] public void IncrementErrorCount_Increments()
|
||||
[Fact] public void RecordLatency_StoresValues()
|
||||
[Fact] public void RollingBuffer_CapsAt1000()
|
||||
[Fact] public void GetSnapshot_IncludesP95AndP99()
|
||||
}
|
||||
```
|
||||
|
||||
### 10.6 Run tests
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests --verbosity normal"
|
||||
```
|
||||
|
||||
## Step 11: Build Verification
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx && dotnet test --verbosity normal"
|
||||
```
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `ILmxProxyClient` interface updated for v2 (TypedValue parameters, onStreamError callback, CheckApiKeyAsync)
|
||||
- [ ] `LmxProxyClient.cs` — main file with Read/Write/WriteBatch/WriteBatchAndWait/CheckApiKey using v2 TypedValue
|
||||
- [ ] `LmxProxyClient.Connection.cs` — ConnectAsync, DisconnectAsync, keep-alive (30s), MarkDisconnectedAsync
|
||||
- [ ] `LmxProxyClient.CodeFirstSubscription.cs` — IAsyncEnumerable processing, onStreamError callback, 5s dispose timeout
|
||||
- [ ] `LmxProxyClient.ClientMetrics.cs` — per-op counts/errors/latency, 1000-sample buffer, p95/p99
|
||||
- [ ] `LmxProxyClient.ApiKeyInfo.cs` — simple DTO
|
||||
- [ ] `LmxProxyClient.ISubscription.cs` — IDisposable + DisposeAsync
|
||||
- [ ] `ClientTlsConfiguration.cs` — all properties present
|
||||
- [ ] `Security/GrpcChannelFactory.cs` — TLS 1.2/1.3, cert validation, custom CA, self-signed support
|
||||
- [ ] No string serialization heuristics anywhere in Client code
|
||||
- [ ] ConvertVtqMessage extracts native TypedValue without parsing
|
||||
- [ ] Polly v8 ResiliencePipeline for retry (not v7 IAsyncPolicy)
|
||||
- [ ] All unit tests pass
|
||||
- [ ] Solution builds cleanly
|
||||
815
lmxproxy/docs/plans/phase-6-client-extras.md
Normal file
815
lmxproxy/docs/plans/phase-6-client-extras.md
Normal file
@@ -0,0 +1,815 @@
|
||||
# Phase 6: Client Extras — Implementation Plan
|
||||
|
||||
**Date**: 2026-03-21
|
||||
**Prerequisites**: Phase 5 complete and passing (Client Core — `ILmxProxyClient`, `LmxProxyClient` partial classes, `ClientMetrics`, `ISubscription`, `ApiKeyInfo` all functional with unit tests passing)
|
||||
**Working Directory**: The lmxproxy repo is on windev at `C:\src\lmxproxy`
|
||||
|
||||
## Guardrails
|
||||
|
||||
1. **Client targets .NET 10, AnyCPU** — latest C# features permitted.
|
||||
2. **Polly v8 API** — `ResiliencePipeline`, `ResiliencePipelineBuilder`, `RetryStrategyOptions`. Do NOT use Polly v7 `IAsyncPolicy`, `Policy.Handle<>().WaitAndRetryAsync(...)`.
|
||||
3. **Builder default port is 50051** (per design doc section 11 — resolved conflict).
|
||||
4. **No new NuGet packages** — `Polly 8.5.2`, `Microsoft.Extensions.DependencyInjection.Abstractions 10.0.0`, `Microsoft.Extensions.Configuration.Abstractions 10.0.0`, `Microsoft.Extensions.Configuration.Binder 10.0.0`, `Microsoft.Extensions.Logging.Abstractions 10.0.0` are already in the csproj.
|
||||
5. **Build command**: `dotnet build src/ZB.MOM.WW.LmxProxy.Client`
|
||||
6. **Test command**: `dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests`
|
||||
|
||||
## Step 1: LmxProxyClientBuilder
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/LmxProxyClientBuilder.cs`
|
||||
|
||||
Rewrite the builder for v2. Key changes from v1:
|
||||
- Default port changes from `5050` to `50051`
|
||||
- Retry uses Polly v8 `ResiliencePipeline` (built in `SetBuilderConfiguration`)
|
||||
- `WithCorrelationIdHeader` support
|
||||
|
||||
### 1.1 Builder fields
|
||||
|
||||
```csharp
|
||||
public class LmxProxyClientBuilder
|
||||
{
|
||||
private string? _host;
|
||||
private int _port = 50051; // CHANGED from 5050
|
||||
private string? _apiKey;
|
||||
private ILogger<LmxProxyClient>? _logger;
|
||||
private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
|
||||
private int _maxRetryAttempts = 3;
|
||||
private TimeSpan _retryDelay = TimeSpan.FromSeconds(1);
|
||||
private bool _enableMetrics;
|
||||
private string? _correlationIdHeader;
|
||||
private ClientTlsConfiguration? _tlsConfiguration;
|
||||
```
|
||||
|
||||
### 1.2 Fluent methods
|
||||
|
||||
Each method returns `this` for chaining. Validation at call site:
|
||||
|
||||
| Method | Default | Validation |
|
||||
|---|---|---|
|
||||
| `WithHost(string host)` | Required | `!string.IsNullOrWhiteSpace(host)` |
|
||||
| `WithPort(int port)` | 50051 | 1-65535 |
|
||||
| `WithApiKey(string? apiKey)` | null | none |
|
||||
| `WithLogger(ILogger<LmxProxyClient> logger)` | NullLogger | `!= null` |
|
||||
| `WithTimeout(TimeSpan timeout)` | 30s | `> TimeSpan.Zero && <= TimeSpan.FromMinutes(10)` |
|
||||
| `WithSslCredentials(string? certificatePath)` | disabled | creates/updates `_tlsConfiguration` with `UseTls=true` |
|
||||
| `WithTlsConfiguration(ClientTlsConfiguration config)` | null | `!= null` |
|
||||
| `WithRetryPolicy(int maxAttempts, TimeSpan retryDelay)` | 3, 1s | `maxAttempts > 0`, `retryDelay > TimeSpan.Zero` |
|
||||
| `WithMetrics()` | disabled | sets `_enableMetrics = true` |
|
||||
| `WithCorrelationIdHeader(string headerName)` | null | `!string.IsNullOrEmpty` |
|
||||
|
||||
### 1.3 Build()
|
||||
|
||||
```csharp
|
||||
public LmxProxyClient Build()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_host))
|
||||
throw new InvalidOperationException("Host must be specified. Call WithHost() before Build().");
|
||||
|
||||
ValidateTlsConfiguration();
|
||||
|
||||
var client = new LmxProxyClient(_host, _port, _apiKey, _tlsConfiguration, _logger)
|
||||
{
|
||||
DefaultTimeout = _defaultTimeout
|
||||
};
|
||||
|
||||
client.SetBuilderConfiguration(new ClientConfiguration
|
||||
{
|
||||
MaxRetryAttempts = _maxRetryAttempts,
|
||||
RetryDelay = _retryDelay,
|
||||
EnableMetrics = _enableMetrics,
|
||||
CorrelationIdHeader = _correlationIdHeader
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 ValidateTlsConfiguration
|
||||
|
||||
If `_tlsConfiguration?.UseTls == true`:
|
||||
- If `ServerCaCertificatePath` is set and file doesn't exist → throw `FileNotFoundException`.
|
||||
- If `ClientCertificatePath` is set and file doesn't exist → throw `FileNotFoundException`.
|
||||
- If `ClientKeyPath` is set and file doesn't exist → throw `FileNotFoundException`.
|
||||
|
||||
### 1.5 Polly v8 ResiliencePipeline setup (in LmxProxyClient.SetBuilderConfiguration)
|
||||
|
||||
This was defined in Step 4 of Phase 5. Verify it uses:
|
||||
|
||||
```csharp
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using Grpc.Core;
|
||||
|
||||
_resiliencePipeline = new ResiliencePipelineBuilder()
|
||||
.AddRetry(new RetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = config.MaxRetryAttempts,
|
||||
Delay = config.RetryDelay,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
ShouldHandle = new PredicateBuilder()
|
||||
.Handle<RpcException>(ex =>
|
||||
ex.StatusCode == StatusCode.Unavailable ||
|
||||
ex.StatusCode == StatusCode.DeadlineExceeded ||
|
||||
ex.StatusCode == StatusCode.ResourceExhausted ||
|
||||
ex.StatusCode == StatusCode.Aborted),
|
||||
OnRetry = args =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Retry {Attempt}/{Max} after {Delay}ms — {Error}",
|
||||
args.AttemptNumber, config.MaxRetryAttempts,
|
||||
args.RetryDelay.TotalMilliseconds,
|
||||
args.Outcome.Exception?.Message ?? "unknown");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
```
|
||||
|
||||
Backoff sequence: `retryDelay * 2^(attempt-1)` → 1s, 2s, 4s for defaults.
|
||||
|
||||
### 1.6 Verify build
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
|
||||
```
|
||||
|
||||
## Step 2: ClientConfiguration
|
||||
|
||||
**File**: This is already defined in `LmxProxyClientBuilder.cs` (at the bottom of the file, as an `internal class`). Verify it contains:
|
||||
|
||||
```csharp
|
||||
internal class ClientConfiguration
|
||||
{
|
||||
public int MaxRetryAttempts { get; set; }
|
||||
public TimeSpan RetryDelay { get; set; }
|
||||
public bool EnableMetrics { get; set; }
|
||||
public string? CorrelationIdHeader { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
No changes needed if it matches.
|
||||
|
||||
## Step 3: ILmxProxyClientFactory + LmxProxyClientFactory
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/ILmxProxyClientFactory.cs`
|
||||
|
||||
### 3.1 Interface
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
public interface ILmxProxyClientFactory
|
||||
{
|
||||
LmxProxyClient CreateClient();
|
||||
LmxProxyClient CreateClient(string configName);
|
||||
LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Implementation
|
||||
|
||||
```csharp
|
||||
public class LmxProxyClientFactory : ILmxProxyClientFactory
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public LmxProxyClientFactory(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
public LmxProxyClient CreateClient() => CreateClient("LmxProxy");
|
||||
|
||||
public LmxProxyClient CreateClient(string configName)
|
||||
{
|
||||
IConfigurationSection section = _configuration.GetSection(configName);
|
||||
var options = new LmxProxyClientOptions();
|
||||
section.Bind(options);
|
||||
return BuildFromOptions(options);
|
||||
}
|
||||
|
||||
public LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction)
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder();
|
||||
builderAction(builder);
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static LmxProxyClient BuildFromOptions(LmxProxyClientOptions options)
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder()
|
||||
.WithHost(options.Host)
|
||||
.WithPort(options.Port)
|
||||
.WithTimeout(options.Timeout)
|
||||
.WithRetryPolicy(options.Retry.MaxAttempts, options.Retry.Delay);
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ApiKey))
|
||||
builder.WithApiKey(options.ApiKey);
|
||||
|
||||
if (options.EnableMetrics)
|
||||
builder.WithMetrics();
|
||||
|
||||
if (!string.IsNullOrEmpty(options.CorrelationIdHeader))
|
||||
builder.WithCorrelationIdHeader(options.CorrelationIdHeader);
|
||||
|
||||
if (options.UseSsl)
|
||||
{
|
||||
builder.WithTlsConfiguration(new ClientTlsConfiguration
|
||||
{
|
||||
UseTls = true,
|
||||
ServerCaCertificatePath = options.CertificatePath
|
||||
});
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Verify build
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
|
||||
```
|
||||
|
||||
## Step 4: ServiceCollectionExtensions
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/ServiceCollectionExtensions.cs`
|
||||
|
||||
### 4.1 Options classes
|
||||
|
||||
Define at the bottom of the file or in a separate `LmxProxyClientOptions.cs`:
|
||||
|
||||
```csharp
|
||||
public class LmxProxyClientOptions
|
||||
{
|
||||
public string Host { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 50051; // CHANGED from 5050
|
||||
public string? ApiKey { get; set; }
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
public bool UseSsl { get; set; }
|
||||
public string? CertificatePath { get; set; }
|
||||
public bool EnableMetrics { get; set; }
|
||||
public string? CorrelationIdHeader { get; set; }
|
||||
public RetryOptions Retry { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RetryOptions
|
||||
{
|
||||
public int MaxAttempts { get; set; } = 3;
|
||||
public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Extension methods
|
||||
|
||||
```csharp
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Registers a singleton ILmxProxyClient from the "LmxProxy" config section.</summary>
|
||||
public static IServiceCollection AddLmxProxyClient(
|
||||
this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
return services.AddLmxProxyClient(configuration, "LmxProxy");
|
||||
}
|
||||
|
||||
/// <summary>Registers a singleton ILmxProxyClient from a named config section.</summary>
|
||||
public static IServiceCollection AddLmxProxyClient(
|
||||
this IServiceCollection services, IConfiguration configuration, string sectionName)
|
||||
{
|
||||
services.AddSingleton<ILmxProxyClientFactory>(
|
||||
sp => new LmxProxyClientFactory(configuration));
|
||||
services.AddSingleton<ILmxProxyClient>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<ILmxProxyClientFactory>();
|
||||
return factory.CreateClient(sectionName);
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Registers a singleton ILmxProxyClient via builder action.</summary>
|
||||
public static IServiceCollection AddLmxProxyClient(
|
||||
this IServiceCollection services, Action<LmxProxyClientBuilder> configure)
|
||||
{
|
||||
services.AddSingleton<ILmxProxyClient>(sp =>
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder();
|
||||
configure(builder);
|
||||
return builder.Build();
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Registers a scoped ILmxProxyClient from the "LmxProxy" config section.</summary>
|
||||
public static IServiceCollection AddScopedLmxProxyClient(
|
||||
this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddSingleton<ILmxProxyClientFactory>(
|
||||
sp => new LmxProxyClientFactory(configuration));
|
||||
services.AddScoped<ILmxProxyClient>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<ILmxProxyClientFactory>();
|
||||
return factory.CreateClient();
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Registers a keyed singleton ILmxProxyClient.</summary>
|
||||
public static IServiceCollection AddNamedLmxProxyClient(
|
||||
this IServiceCollection services, string name, Action<LmxProxyClientBuilder> configure)
|
||||
{
|
||||
services.AddKeyedSingleton<ILmxProxyClient>(name, (sp, key) =>
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder();
|
||||
configure(builder);
|
||||
return builder.Build();
|
||||
});
|
||||
return services;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Verify build
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
|
||||
```
|
||||
|
||||
## Step 5: StreamingExtensions
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/StreamingExtensions.cs`
|
||||
|
||||
### 5.1 ReadStreamAsync
|
||||
|
||||
```csharp
|
||||
public static class StreamingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads multiple tags as an async stream in batches.
|
||||
/// Retries up to 2 times per batch. Aborts after 3 consecutive batch errors.
|
||||
/// </summary>
|
||||
public static async IAsyncEnumerable<KeyValuePair<string, Vtq>> ReadStreamAsync(
|
||||
this ILmxProxyClient client,
|
||||
IEnumerable<string> addresses,
|
||||
int batchSize = 100,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(addresses);
|
||||
if (batchSize <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize));
|
||||
|
||||
var batch = new List<string>(batchSize);
|
||||
int consecutiveErrors = 0;
|
||||
const int maxConsecutiveErrors = 3;
|
||||
const int maxRetries = 2;
|
||||
|
||||
foreach (string address in addresses)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
batch.Add(address);
|
||||
|
||||
if (batch.Count >= batchSize)
|
||||
{
|
||||
await foreach (var kvp in ReadBatchWithRetry(
|
||||
client, batch, maxRetries, cancellationToken))
|
||||
{
|
||||
consecutiveErrors = 0;
|
||||
yield return kvp;
|
||||
}
|
||||
// If we get here without yielding, it was an error
|
||||
// (handled inside ReadBatchWithRetry)
|
||||
batch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
await foreach (var kvp in ReadBatchWithRetry(
|
||||
client, batch, maxRetries, cancellationToken))
|
||||
{
|
||||
yield return kvp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<KeyValuePair<string, Vtq>> ReadBatchWithRetry(
|
||||
ILmxProxyClient client,
|
||||
List<string> batch,
|
||||
int maxRetries,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
int retries = 0;
|
||||
while (retries <= maxRetries)
|
||||
{
|
||||
IDictionary<string, Vtq>? results = null;
|
||||
try
|
||||
{
|
||||
results = await client.ReadBatchAsync(batch, ct);
|
||||
}
|
||||
catch when (retries < maxRetries)
|
||||
{
|
||||
retries++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (results is not null)
|
||||
{
|
||||
foreach (var kvp in results)
|
||||
yield return kvp;
|
||||
yield break;
|
||||
}
|
||||
retries++;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 WriteStreamAsync
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Writes values from an async enumerable in batches. Returns total count written.
|
||||
/// </summary>
|
||||
public static async Task<int> WriteStreamAsync(
|
||||
this ILmxProxyClient client,
|
||||
IAsyncEnumerable<KeyValuePair<string, TypedValue>> values,
|
||||
int batchSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (batchSize <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize));
|
||||
|
||||
var batch = new Dictionary<string, TypedValue>(batchSize);
|
||||
int totalWritten = 0;
|
||||
|
||||
await foreach (var kvp in values.WithCancellation(cancellationToken))
|
||||
{
|
||||
batch[kvp.Key] = kvp.Value;
|
||||
|
||||
if (batch.Count >= batchSize)
|
||||
{
|
||||
await client.WriteBatchAsync(batch, cancellationToken);
|
||||
totalWritten += batch.Count;
|
||||
batch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
await client.WriteBatchAsync(batch, cancellationToken);
|
||||
totalWritten += batch.Count;
|
||||
}
|
||||
|
||||
return totalWritten;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 ProcessInParallelAsync
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Processes items in parallel with a configurable max concurrency (default 4).
|
||||
/// </summary>
|
||||
public static async Task ProcessInParallelAsync<T>(
|
||||
this IAsyncEnumerable<T> source,
|
||||
Func<T, CancellationToken, Task> processor,
|
||||
int maxConcurrency = 4,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(processor);
|
||||
if (maxConcurrency <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxConcurrency));
|
||||
|
||||
using var semaphore = new SemaphoreSlim(maxConcurrency);
|
||||
var tasks = new List<Task>();
|
||||
|
||||
await foreach (T item in source.WithCancellation(cancellationToken))
|
||||
{
|
||||
await semaphore.WaitAsync(cancellationToken);
|
||||
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await processor(item, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}, cancellationToken));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 SubscribeStreamAsync
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Wraps a callback-based subscription into an IAsyncEnumerable via System.Threading.Channels.
|
||||
/// </summary>
|
||||
public static async IAsyncEnumerable<(string Tag, Vtq Vtq)> SubscribeStreamAsync(
|
||||
this ILmxProxyClient client,
|
||||
IEnumerable<string> addresses,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(addresses);
|
||||
|
||||
var channel = Channel.CreateBounded<(string, Vtq)>(
|
||||
new BoundedChannelOptions(1000)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
ISubscription? subscription = null;
|
||||
try
|
||||
{
|
||||
subscription = await client.SubscribeAsync(
|
||||
addresses,
|
||||
(tag, vtq) =>
|
||||
{
|
||||
channel.Writer.TryWrite((tag, vtq));
|
||||
},
|
||||
ex =>
|
||||
{
|
||||
channel.Writer.TryComplete(ex);
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
subscription?.Dispose();
|
||||
channel.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 Verify build
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
|
||||
```
|
||||
|
||||
## Step 6: Properties/AssemblyInfo.cs
|
||||
|
||||
**File**: `src/ZB.MOM.WW.LmxProxy.Client/Properties/AssemblyInfo.cs`
|
||||
|
||||
Create this file if it doesn't already exist:
|
||||
|
||||
```csharp
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("ZB.MOM.WW.LmxProxy.Client.Tests")]
|
||||
```
|
||||
|
||||
This allows the test project to access `internal` types like `ClientMetrics` and `ClientConfiguration`.
|
||||
|
||||
### 6.1 Verify build
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build src/ZB.MOM.WW.LmxProxy.Client"
|
||||
```
|
||||
|
||||
## Step 7: Unit Tests
|
||||
|
||||
Add tests to the existing `tests/ZB.MOM.WW.LmxProxy.Client.Tests/` project (created in Phase 5).
|
||||
|
||||
### 7.1 Builder Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientBuilderTests.cs`
|
||||
|
||||
```csharp
|
||||
public class LmxProxyClientBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ThrowsWhenHostNotSet()
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder();
|
||||
Assert.Throws<InvalidOperationException>(() => builder.Build());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DefaultPort_Is50051()
|
||||
{
|
||||
var client = new LmxProxyClientBuilder()
|
||||
.WithHost("localhost")
|
||||
.Build();
|
||||
// Verify via reflection or by checking connection attempt URI
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithPort_ThrowsOnZero()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new LmxProxyClientBuilder().WithPort(0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithPort_ThrowsOn65536()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new LmxProxyClientBuilder().WithPort(65536));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithTimeout_ThrowsOnNegative()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromSeconds(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithTimeout_ThrowsOver10Minutes()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromMinutes(11)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithRetryPolicy_ThrowsOnZeroAttempts()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new LmxProxyClientBuilder().WithRetryPolicy(0, TimeSpan.FromSeconds(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithRetryPolicy_ThrowsOnZeroDelay()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
new LmxProxyClientBuilder().WithRetryPolicy(3, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithAllOptions_Succeeds()
|
||||
{
|
||||
var client = new LmxProxyClientBuilder()
|
||||
.WithHost("10.100.0.48")
|
||||
.WithPort(50051)
|
||||
.WithApiKey("test-key")
|
||||
.WithTimeout(TimeSpan.FromSeconds(15))
|
||||
.WithRetryPolicy(5, TimeSpan.FromSeconds(2))
|
||||
.WithMetrics()
|
||||
.WithCorrelationIdHeader("X-Correlation-ID")
|
||||
.Build();
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithTls_ValidatesCertificatePaths()
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder()
|
||||
.WithHost("localhost")
|
||||
.WithTlsConfiguration(new ClientTlsConfiguration
|
||||
{
|
||||
UseTls = true,
|
||||
ServerCaCertificatePath = "/nonexistent/cert.pem"
|
||||
});
|
||||
Assert.Throws<FileNotFoundException>(() => builder.Build());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithHost_ThrowsOnNull()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new LmxProxyClientBuilder().WithHost(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithHost_ThrowsOnEmpty()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
new LmxProxyClientBuilder().WithHost(""));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Factory Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/LmxProxyClientFactoryTests.cs`
|
||||
|
||||
```csharp
|
||||
public class LmxProxyClientFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateClient_BindsFromConfiguration()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["LmxProxy:Host"] = "10.100.0.48",
|
||||
["LmxProxy:Port"] = "50052",
|
||||
["LmxProxy:ApiKey"] = "test-key",
|
||||
["LmxProxy:Retry:MaxAttempts"] = "5",
|
||||
["LmxProxy:Retry:Delay"] = "00:00:02",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var factory = new LmxProxyClientFactory(config);
|
||||
var client = factory.CreateClient();
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateClient_NamedSection()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MyProxy:Host"] = "10.100.0.48",
|
||||
["MyProxy:Port"] = "50052",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var factory = new LmxProxyClientFactory(config);
|
||||
var client = factory.CreateClient("MyProxy");
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateClient_BuilderAction()
|
||||
{
|
||||
var config = new ConfigurationBuilder().Build();
|
||||
var factory = new LmxProxyClientFactory(config);
|
||||
var client = factory.CreateClient(b => b.WithHost("localhost").WithPort(50051));
|
||||
Assert.NotNull(client);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 StreamingExtensions Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.Tests/StreamingExtensionsTests.cs`
|
||||
|
||||
```csharp
|
||||
public class StreamingExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadStreamAsync_BatchesCorrectly()
|
||||
// Create mock client, provide 250 addresses with batchSize=100
|
||||
// Verify ReadBatchAsync called 3 times (100, 100, 50)
|
||||
|
||||
[Fact]
|
||||
public async Task ReadStreamAsync_RetriesOnError()
|
||||
// Mock first ReadBatchAsync to throw, second to succeed
|
||||
// Verify results returned from second attempt
|
||||
|
||||
[Fact]
|
||||
public async Task WriteStreamAsync_BatchesAndReturnsCount()
|
||||
// Provide async enumerable of 250 items, batchSize=100
|
||||
// Verify WriteBatchAsync called 3 times, total returned = 250
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInParallelAsync_RespectsMaxConcurrency()
|
||||
// Track concurrent count with SemaphoreSlim
|
||||
// maxConcurrency=2, verify never exceeds 2 concurrent calls
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeStreamAsync_YieldsFromChannel()
|
||||
// Mock SubscribeAsync to invoke onUpdate callback with test values
|
||||
// Verify IAsyncEnumerable yields matching items
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 Run all tests
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet test tests/ZB.MOM.WW.LmxProxy.Client.Tests --verbosity normal"
|
||||
```
|
||||
|
||||
## Step 8: Build Verification
|
||||
|
||||
Run full solution build and all tests:
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet build ZB.MOM.WW.LmxProxy.slnx && dotnet test --verbosity normal"
|
||||
```
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] `LmxProxyClientBuilder` with default port 50051, Polly v8 wiring, all fluent methods, TLS validation
|
||||
- [ ] `ClientConfiguration` internal record with retry, metrics, correlation header fields
|
||||
- [ ] `ILmxProxyClientFactory` + `LmxProxyClientFactory` with 3 `CreateClient` overloads
|
||||
- [ ] `ServiceCollectionExtensions` with `AddLmxProxyClient` (3 overloads), `AddScopedLmxProxyClient`, `AddNamedLmxProxyClient`
|
||||
- [ ] `LmxProxyClientOptions` + `RetryOptions` configuration classes
|
||||
- [ ] `StreamingExtensions` with `ReadStreamAsync` (batched, 2 retries, 3 consecutive error abort), `WriteStreamAsync` (batched), `ProcessInParallelAsync` (SemaphoreSlim, max 4), `SubscribeStreamAsync` (Channel-based IAsyncEnumerable)
|
||||
- [ ] `Properties/AssemblyInfo.cs` with `InternalsVisibleTo` for test project
|
||||
- [ ] Builder tests: validation, defaults, Polly pipeline wiring, TLS cert validation
|
||||
- [ ] Factory tests: config binding from IConfiguration, named sections, builder action
|
||||
- [ ] StreamingExtensions tests: batching, error recovery, parallel throttling, subscription streaming
|
||||
- [ ] Solution builds cleanly
|
||||
- [ ] All tests pass
|
||||
793
lmxproxy/docs/plans/phase-7-integration-deployment.md
Normal file
793
lmxproxy/docs/plans/phase-7-integration-deployment.md
Normal file
@@ -0,0 +1,793 @@
|
||||
# Phase 7: Integration Tests & Deployment — Implementation Plan
|
||||
|
||||
**Date**: 2026-03-21
|
||||
**Prerequisites**: Phase 4 (Host complete) and Phase 6 (Client complete) both passing. All unit tests green.
|
||||
**Working Directory (Mac)**: `/Users/dohertj2/Desktop/scadalink-design/lmxproxy`
|
||||
**Working Directory (windev)**: `C:\src\lmxproxy`
|
||||
**windev SSH**: `ssh windev` (alias configured in `~/.ssh/config`, passwordless ed25519, user `dohertj2`)
|
||||
|
||||
## Guardrails
|
||||
|
||||
1. **Never stop the v1 service until v2 is verified** — deploy v2 on alternate ports first.
|
||||
2. **Take a Veeam backup before cutover** — provides rollback point.
|
||||
3. **Integration tests run from Mac against windev** — they use `Grpc.Net.Client` which is cross-platform.
|
||||
4. **All integration tests must pass before cutover**.
|
||||
5. **API keys**: The existing `apikeys.json` on windev is the source of truth for valid keys. Read it to get test keys.
|
||||
6. **Real MxAccess tags**: Use tags from the JoeAppEngine namespace which is the live AVEVA System Platform instance on windev.
|
||||
|
||||
## Step 1: Build Host on windev
|
||||
|
||||
### 1.1 Pull latest code
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && git pull"
|
||||
```
|
||||
|
||||
If the repo doesn't exist on windev yet:
|
||||
|
||||
```bash
|
||||
ssh windev "git clone https://gitea.dohertylan.com/dohertj2/lmxproxy.git C:\src\lmxproxy"
|
||||
```
|
||||
|
||||
### 1.2 Publish Host binary
|
||||
|
||||
```bash
|
||||
ssh windev "cd C:\src\lmxproxy && dotnet publish src/ZB.MOM.WW.LmxProxy.Host -c Release -r win-x86 --self-contained false -o C:\publish-v2\"
|
||||
```
|
||||
|
||||
**Expected output**: `C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe` plus dependencies.
|
||||
|
||||
### 1.3 Create v2 appsettings.json
|
||||
|
||||
Create `C:\publish-v2\appsettings.json` configured for testing on alternate ports:
|
||||
|
||||
```bash
|
||||
ssh windev "powershell -Command \"@'
|
||||
{
|
||||
\"GrpcPort\": 50052,
|
||||
\"ApiKeyConfigFile\": \"apikeys.json\",
|
||||
\"Connection\": {
|
||||
\"MonitorIntervalSeconds\": 5,
|
||||
\"ConnectionTimeoutSeconds\": 30,
|
||||
\"ReadTimeoutSeconds\": 5,
|
||||
\"WriteTimeoutSeconds\": 5,
|
||||
\"MaxConcurrentOperations\": 10,
|
||||
\"AutoReconnect\": true
|
||||
},
|
||||
\"Subscription\": {
|
||||
\"ChannelCapacity\": 1000,
|
||||
\"ChannelFullMode\": \"DropOldest\"
|
||||
},
|
||||
\"Tls\": {
|
||||
\"Enabled\": false
|
||||
},
|
||||
\"WebServer\": {
|
||||
\"Enabled\": true,
|
||||
\"Port\": 8081
|
||||
},
|
||||
\"Serilog\": {
|
||||
\"MinimumLevel\": {
|
||||
\"Default\": \"Information\",
|
||||
\"Override\": {
|
||||
\"Microsoft\": \"Warning\",
|
||||
\"System\": \"Warning\",
|
||||
\"Grpc\": \"Information\"
|
||||
}
|
||||
},
|
||||
\"WriteTo\": [
|
||||
{ \"Name\": \"Console\" },
|
||||
{
|
||||
\"Name\": \"File\",
|
||||
\"Args\": {
|
||||
\"path\": \"logs/lmxproxy-v2-.txt\",
|
||||
\"rollingInterval\": \"Day\",
|
||||
\"retainedFileCountLimit\": 30
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
'@ | Set-Content -Path 'C:\publish-v2\appsettings.json' -Encoding UTF8\""
|
||||
```
|
||||
|
||||
**Key differences from production config**: gRPC port is 50052 (not 50051), web port is 8081 (not 8080), log file prefix is `lmxproxy-v2-`.
|
||||
|
||||
### 1.4 Copy apikeys.json
|
||||
|
||||
If v2 should use the same API keys as v1:
|
||||
|
||||
```bash
|
||||
ssh windev "copy C:\publish\apikeys.json C:\publish-v2\apikeys.json"
|
||||
```
|
||||
|
||||
If `C:\publish\apikeys.json` doesn't exist (the v2 service will auto-generate one on first start):
|
||||
|
||||
```bash
|
||||
ssh windev "if not exist C:\publish\apikeys.json echo No existing apikeys.json - v2 will auto-generate"
|
||||
```
|
||||
|
||||
### 1.5 Verify the publish directory
|
||||
|
||||
```bash
|
||||
ssh windev "dir C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe && dir C:\publish-v2\appsettings.json"
|
||||
```
|
||||
|
||||
## Step 2: Deploy v2 Host Service
|
||||
|
||||
### 2.1 Install as a separate Topshelf service
|
||||
|
||||
The v2 service runs alongside v1 on different ports. Install with a distinct service name:
|
||||
|
||||
```bash
|
||||
ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe install -servicename \"ZB.MOM.WW.LmxProxy.Host.V2\" -displayname \"SCADA Bridge LMX Proxy V2\" -description \"LmxProxy v2 gRPC service (test deployment)\" --autostart"
|
||||
```
|
||||
|
||||
### 2.2 Start the v2 service
|
||||
|
||||
```bash
|
||||
ssh windev "sc start ZB.MOM.WW.LmxProxy.Host.V2"
|
||||
```
|
||||
|
||||
### 2.3 Wait 10 seconds for startup, then verify
|
||||
|
||||
```bash
|
||||
ssh windev "timeout /t 10 /nobreak >nul && sc query ZB.MOM.WW.LmxProxy.Host.V2"
|
||||
```
|
||||
|
||||
Expected: `STATE: 4 RUNNING`.
|
||||
|
||||
### 2.4 Verify status page
|
||||
|
||||
From Mac, use curl to check the v2 status page:
|
||||
|
||||
```bash
|
||||
curl -s http://10.100.0.48:8081/ | head -20
|
||||
```
|
||||
|
||||
Expected: HTML containing "LmxProxy Status Dashboard".
|
||||
|
||||
```bash
|
||||
curl -s http://10.100.0.48:8081/api/health
|
||||
```
|
||||
|
||||
Expected: `OK` with HTTP 200.
|
||||
|
||||
```bash
|
||||
curl -s http://10.100.0.48:8081/api/status | python3 -m json.tool | head -30
|
||||
```
|
||||
|
||||
Expected: JSON with `serviceName`, `connection.isConnected: true`, version info.
|
||||
|
||||
### 2.5 Verify MxAccess connected
|
||||
|
||||
The status page should show `MxAccess Connection: Connected`. If it shows `Disconnected`, check the logs:
|
||||
|
||||
```bash
|
||||
ssh windev "type C:\publish-v2\logs\lmxproxy-v2-*.txt | findstr /i \"error\""
|
||||
```
|
||||
|
||||
### 2.6 Read the apikeys.json to get test keys
|
||||
|
||||
```bash
|
||||
ssh windev "type C:\publish-v2\apikeys.json"
|
||||
```
|
||||
|
||||
Record the ReadWrite and ReadOnly API keys for use in integration tests. Example structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"Keys": [
|
||||
{ "Key": "abc123...", "Role": "ReadWrite", "Description": "Default ReadWrite key" },
|
||||
{ "Key": "def456...", "Role": "ReadOnly", "Description": "Default ReadOnly key" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Create Integration Test Project
|
||||
|
||||
### 3.1 Create project
|
||||
|
||||
On windev (or Mac — the test project is .NET 10 and cross-platform):
|
||||
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy
|
||||
dotnet new xunit -n ZB.MOM.WW.LmxProxy.Client.IntegrationTests -o tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --framework net10.0
|
||||
```
|
||||
|
||||
### 3.2 Configure csproj
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj`
|
||||
|
||||
```xml
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Client\ZB.MOM.WW.LmxProxy.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.test.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
```
|
||||
|
||||
### 3.3 Add to solution
|
||||
|
||||
Edit `ZB.MOM.WW.LmxProxy.slnx`:
|
||||
|
||||
```xml
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/ZB.MOM.WW.LmxProxy.Host/ZB.MOM.WW.LmxProxy.Host.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.LmxProxy.Client/ZB.MOM.WW.LmxProxy.Client.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ZB.MOM.WW.LmxProxy.Host.Tests/ZB.MOM.WW.LmxProxy.Host.Tests.csproj" />
|
||||
<Project Path="tests/ZB.MOM.WW.LmxProxy.Client.Tests/ZB.MOM.WW.LmxProxy.Client.Tests.csproj" />
|
||||
<Project Path="tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
```
|
||||
|
||||
### 3.4 Create test configuration
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"LmxProxy": {
|
||||
"Host": "10.100.0.48",
|
||||
"Port": 50052,
|
||||
"ReadWriteApiKey": "REPLACE_WITH_ACTUAL_KEY",
|
||||
"ReadOnlyApiKey": "REPLACE_WITH_ACTUAL_KEY",
|
||||
"InvalidApiKey": "invalid-key-that-does-not-exist"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**IMPORTANT**: After reading the actual `apikeys.json` from windev in Step 2.6, replace the placeholder values with the real keys.
|
||||
|
||||
### 3.5 Create test base class
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/IntegrationTestBase.cs`
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public abstract class IntegrationTestBase : IAsyncLifetime
|
||||
{
|
||||
protected IConfiguration Configuration { get; }
|
||||
protected string Host { get; }
|
||||
protected int Port { get; }
|
||||
protected string ReadWriteApiKey { get; }
|
||||
protected string ReadOnlyApiKey { get; }
|
||||
protected string InvalidApiKey { get; }
|
||||
protected LmxProxyClient? Client { get; set; }
|
||||
|
||||
protected IntegrationTestBase()
|
||||
{
|
||||
Configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.test.json")
|
||||
.Build();
|
||||
|
||||
var section = Configuration.GetSection("LmxProxy");
|
||||
Host = section["Host"] ?? "10.100.0.48";
|
||||
Port = int.Parse(section["Port"] ?? "50052");
|
||||
ReadWriteApiKey = section["ReadWriteApiKey"] ?? throw new Exception("ReadWriteApiKey not configured");
|
||||
ReadOnlyApiKey = section["ReadOnlyApiKey"] ?? throw new Exception("ReadOnlyApiKey not configured");
|
||||
InvalidApiKey = section["InvalidApiKey"] ?? "invalid-key";
|
||||
}
|
||||
|
||||
protected LmxProxyClient CreateClient(string? apiKey = null)
|
||||
{
|
||||
return new LmxProxyClientBuilder()
|
||||
.WithHost(Host)
|
||||
.WithPort(Port)
|
||||
.WithApiKey(apiKey ?? ReadWriteApiKey)
|
||||
.WithTimeout(TimeSpan.FromSeconds(10))
|
||||
.WithRetryPolicy(2, TimeSpan.FromSeconds(1))
|
||||
.WithMetrics()
|
||||
.Build();
|
||||
}
|
||||
|
||||
public virtual async Task InitializeAsync()
|
||||
{
|
||||
Client = CreateClient();
|
||||
await Client.ConnectAsync();
|
||||
}
|
||||
|
||||
public virtual async Task DisposeAsync()
|
||||
{
|
||||
if (Client is not null)
|
||||
{
|
||||
await Client.DisconnectAsync();
|
||||
Client.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Integration Test Scenarios
|
||||
|
||||
### 4.1 Connection Lifecycle
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ConnectionTests.cs`
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public class ConnectionTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConnectAndDisconnect_Succeeds()
|
||||
{
|
||||
// Client is connected in InitializeAsync
|
||||
Assert.True(await Client!.IsConnectedAsync());
|
||||
await Client.DisconnectAsync();
|
||||
Assert.False(await Client.IsConnectedAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectWithInvalidApiKey_Fails()
|
||||
{
|
||||
using var badClient = CreateClient(InvalidApiKey);
|
||||
// Expect RpcException with StatusCode.Unauthenticated
|
||||
var ex = await Assert.ThrowsAsync<Grpc.Core.RpcException>(
|
||||
() => badClient.ConnectAsync());
|
||||
Assert.Equal(Grpc.Core.StatusCode.Unauthenticated, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DoubleConnect_IsIdempotent()
|
||||
{
|
||||
await Client!.ConnectAsync(); // Already connected — should be no-op
|
||||
Assert.True(await Client.IsConnectedAsync());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Read Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/ReadTests.cs`
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public class ReadTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task Read_StringTag_ReturnsStringValue()
|
||||
{
|
||||
// JoeAppEngine.Area is a string attribute that should return "JoeDev"
|
||||
var vtq = await Client!.ReadAsync("JoeAppEngine.Area");
|
||||
Assert.NotNull(vtq.Value);
|
||||
Assert.IsType<string>(vtq.Value);
|
||||
Assert.Equal("JoeDev", vtq.Value);
|
||||
// Quality should be Good (check via QualityExtensions.IsGood if available,
|
||||
// or check vtq.Quality == Quality.Good)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_WritableTag_ReturnsTypedValue()
|
||||
{
|
||||
// JoeAppEngine.BTCS is a writable tag
|
||||
var vtq = await Client!.ReadAsync("JoeAppEngine.BTCS");
|
||||
Assert.NotNull(vtq.Value);
|
||||
// Verify timestamp is recent (within last hour)
|
||||
Assert.True(DateTime.UtcNow - vtq.Timestamp < TimeSpan.FromHours(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadBatch_MultiplesTags_ReturnsDictionary()
|
||||
{
|
||||
var tags = new[] { "JoeAppEngine.Area", "JoeAppEngine.BTCS" };
|
||||
var results = await Client!.ReadBatchAsync(tags);
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.True(results.ContainsKey("JoeAppEngine.Area"));
|
||||
Assert.True(results.ContainsKey("JoeAppEngine.BTCS"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_NonexistentTag_ReturnsBadQuality()
|
||||
{
|
||||
// Reading a tag that doesn't exist should return Bad quality
|
||||
// (or throw — depends on Host implementation. Adjust assertion accordingly.)
|
||||
var vtq = await Client!.ReadAsync("NonExistent.Tag.12345");
|
||||
// If the Host returns success=false, ReadAsync will throw.
|
||||
// If it returns success=true with bad quality, check quality.
|
||||
// Adjust based on actual behavior.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Write Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteTests.cs`
|
||||
|
||||
```csharp
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public class WriteTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteAndReadBack_StringValue()
|
||||
{
|
||||
string testValue = $"IntTest-{DateTime.UtcNow:HHmmss}";
|
||||
// Write to a writable string tag
|
||||
await Client!.WriteAsync("JoeAppEngine.BTCS",
|
||||
new TypedValue { StringValue = testValue });
|
||||
|
||||
// Read back and verify
|
||||
await Task.Delay(500); // Allow time for write to propagate
|
||||
var vtq = await Client.ReadAsync("JoeAppEngine.BTCS");
|
||||
Assert.Equal(testValue, vtq.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteWithReadOnlyKey_ThrowsPermissionDenied()
|
||||
{
|
||||
using var readOnlyClient = CreateClient(ReadOnlyApiKey);
|
||||
await readOnlyClient.ConnectAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<Grpc.Core.RpcException>(
|
||||
() => readOnlyClient.WriteAsync("JoeAppEngine.BTCS",
|
||||
new TypedValue { StringValue = "should-fail" }));
|
||||
Assert.Equal(Grpc.Core.StatusCode.PermissionDenied, ex.StatusCode);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Subscribe Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/SubscribeTests.cs`
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public class SubscribeTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task Subscribe_ReceivesUpdates()
|
||||
{
|
||||
var received = new List<(string Tag, Vtq Vtq)>();
|
||||
var receivedEvent = new TaskCompletionSource<bool>();
|
||||
|
||||
var subscription = await Client!.SubscribeAsync(
|
||||
new[] { "JoeAppEngine.Scheduler.ScanTime" },
|
||||
(tag, vtq) =>
|
||||
{
|
||||
received.Add((tag, vtq));
|
||||
if (received.Count >= 3)
|
||||
receivedEvent.TrySetResult(true);
|
||||
},
|
||||
ex => receivedEvent.TrySetException(ex));
|
||||
|
||||
// Wait up to 30 seconds for at least 3 updates
|
||||
var completed = await Task.WhenAny(receivedEvent.Task, Task.Delay(TimeSpan.FromSeconds(30)));
|
||||
subscription.Dispose();
|
||||
|
||||
Assert.True(received.Count >= 1, $"Expected at least 1 update, got {received.Count}");
|
||||
|
||||
// Verify the VTQ has correct structure
|
||||
var first = received[0];
|
||||
Assert.Equal("JoeAppEngine.Scheduler.ScanTime", first.Tag);
|
||||
Assert.NotNull(first.Vtq.Value);
|
||||
// ScanTime should be a DateTime value
|
||||
Assert.True(first.Vtq.Timestamp > DateTime.MinValue);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 WriteBatchAndWait Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/WriteBatchAndWaitTests.cs`
|
||||
|
||||
```csharp
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public class WriteBatchAndWaitTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task WriteBatchAndWait_TypeAwareComparison()
|
||||
{
|
||||
// This test requires a writable tag and a flag tag.
|
||||
// Adjust tag names based on available tags in JoeAppEngine.
|
||||
// Example: write values and poll a flag.
|
||||
|
||||
var values = new Dictionary<string, TypedValue>
|
||||
{
|
||||
["JoeAppEngine.BTCS"] = new TypedValue { StringValue = "BatchTest" }
|
||||
};
|
||||
|
||||
// Poll the same tag we wrote to (simple self-check)
|
||||
var response = await Client!.WriteBatchAndWaitAsync(
|
||||
values,
|
||||
flagTag: "JoeAppEngine.BTCS",
|
||||
flagValue: new TypedValue { StringValue = "BatchTest" },
|
||||
timeoutMs: 5000,
|
||||
pollIntervalMs: 200);
|
||||
|
||||
Assert.True(response.Success);
|
||||
Assert.True(response.FlagReached);
|
||||
Assert.True(response.ElapsedMs < 5000);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 CheckApiKey Tests
|
||||
|
||||
**File**: `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/CheckApiKeyTests.cs`
|
||||
|
||||
```csharp
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.IntegrationTests;
|
||||
|
||||
public class CheckApiKeyTests : IntegrationTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckApiKey_ValidReadWrite_ReturnsValid()
|
||||
{
|
||||
var info = await Client!.CheckApiKeyAsync(ReadWriteApiKey);
|
||||
Assert.True(info.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckApiKey_ValidReadOnly_ReturnsValid()
|
||||
{
|
||||
var info = await Client!.CheckApiKeyAsync(ReadOnlyApiKey);
|
||||
Assert.True(info.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckApiKey_Invalid_ReturnsInvalid()
|
||||
{
|
||||
var info = await Client!.CheckApiKeyAsync("totally-invalid-key-12345");
|
||||
Assert.False(info.IsValid);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Run Integration Tests
|
||||
|
||||
### 5.1 Build the test project (from Mac)
|
||||
|
||||
```bash
|
||||
cd /Users/dohertj2/Desktop/scadalink-design/lmxproxy
|
||||
dotnet build tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests
|
||||
```
|
||||
|
||||
### 5.2 Run integration tests against v2 on alternate port
|
||||
|
||||
```bash
|
||||
dotnet test tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --verbosity normal
|
||||
```
|
||||
|
||||
All tests should pass against `10.100.0.48:50052`.
|
||||
|
||||
### 5.3 Debug failures
|
||||
|
||||
If tests fail, check:
|
||||
1. v2 service is running: `ssh windev "sc query ZB.MOM.WW.LmxProxy.Host.V2"`
|
||||
2. v2 service logs: `ssh windev "type C:\publish-v2\logs\lmxproxy-v2-*.txt | findstr /i error"`
|
||||
3. Network connectivity: `curl -s http://10.100.0.48:8081/api/health`
|
||||
4. API keys match: `ssh windev "type C:\publish-v2\apikeys.json"`
|
||||
|
||||
### 5.4 Verify metrics after test run
|
||||
|
||||
```bash
|
||||
curl -s http://10.100.0.48:8081/api/status | python3 -m json.tool
|
||||
```
|
||||
|
||||
Should show non-zero operation counts for Read, ReadBatch, Write, etc.
|
||||
|
||||
## Step 6: Cutover
|
||||
|
||||
**Only proceed if ALL integration tests pass.**
|
||||
|
||||
### 6.1 Stop v1 service
|
||||
|
||||
```bash
|
||||
ssh windev "sc stop ZB.MOM.WW.LmxProxy.Host"
|
||||
```
|
||||
|
||||
Verify stopped:
|
||||
|
||||
```bash
|
||||
ssh windev "sc query ZB.MOM.WW.LmxProxy.Host"
|
||||
```
|
||||
|
||||
Expected: `STATE: 1 STOPPED`.
|
||||
|
||||
### 6.2 Stop v2 service
|
||||
|
||||
```bash
|
||||
ssh windev "sc stop ZB.MOM.WW.LmxProxy.Host.V2"
|
||||
```
|
||||
|
||||
### 6.3 Reconfigure v2 to production ports
|
||||
|
||||
Update `C:\publish-v2\appsettings.json`:
|
||||
- Change `GrpcPort` from `50052` to `50051`
|
||||
- Change `WebServer.Port` from `8081` to `8080`
|
||||
- Change log file prefix from `lmxproxy-v2-` to `lmxproxy-`
|
||||
|
||||
```bash
|
||||
ssh windev "powershell -Command \"(Get-Content 'C:\publish-v2\appsettings.json') -replace '50052','50051' -replace '8081','8080' -replace 'lmxproxy-v2-','lmxproxy-' | Set-Content 'C:\publish-v2\appsettings.json'\""
|
||||
```
|
||||
|
||||
### 6.4 Uninstall v1 service
|
||||
|
||||
```bash
|
||||
ssh windev "C:\publish\ZB.MOM.WW.LmxProxy.Host.exe uninstall -servicename \"ZB.MOM.WW.LmxProxy.Host\""
|
||||
```
|
||||
|
||||
### 6.5 Uninstall v2 test service and reinstall as production service
|
||||
|
||||
```bash
|
||||
ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe uninstall -servicename \"ZB.MOM.WW.LmxProxy.Host.V2\""
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh windev "C:\publish-v2\ZB.MOM.WW.LmxProxy.Host.exe install -servicename \"ZB.MOM.WW.LmxProxy.Host\" -displayname \"SCADA Bridge LMX Proxy\" -description \"LmxProxy v2 gRPC service\" --autostart"
|
||||
```
|
||||
|
||||
### 6.6 Start the production service
|
||||
|
||||
```bash
|
||||
ssh windev "sc start ZB.MOM.WW.LmxProxy.Host"
|
||||
```
|
||||
|
||||
### 6.7 Verify on production ports
|
||||
|
||||
```bash
|
||||
ssh windev "timeout /t 10 /nobreak >nul && sc query ZB.MOM.WW.LmxProxy.Host"
|
||||
```
|
||||
|
||||
Expected: `STATE: 4 RUNNING`.
|
||||
|
||||
```bash
|
||||
curl -s http://10.100.0.48:8080/api/health
|
||||
```
|
||||
|
||||
Expected: `OK`.
|
||||
|
||||
```bash
|
||||
curl -s http://10.100.0.48:8080/api/status | python3 -m json.tool | head -15
|
||||
```
|
||||
|
||||
Expected: Connected, version shows v2.
|
||||
|
||||
### 6.8 Update test configuration and re-run integration tests
|
||||
|
||||
Update `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/appsettings.test.json`:
|
||||
- Change `Port` from `50052` to `50051`
|
||||
|
||||
```bash
|
||||
dotnet test tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests --verbosity normal
|
||||
```
|
||||
|
||||
All tests should pass on the production port.
|
||||
|
||||
### 6.9 Configure service recovery
|
||||
|
||||
```bash
|
||||
ssh windev "sc failure ZB.MOM.WW.LmxProxy.Host reset= 86400 actions= restart/60000/restart/300000/restart/600000"
|
||||
```
|
||||
|
||||
This configures: restart after 1 min on first failure, 5 min on second, 10 min on subsequent. Reset counter after 1 day (86400 seconds).
|
||||
|
||||
## Step 7: Documentation Updates
|
||||
|
||||
### 7.1 Update windev.md
|
||||
|
||||
Add a section about the LmxProxy v2 service to `/Users/dohertj2/Desktop/scadalink-design/windev.md`:
|
||||
|
||||
```markdown
|
||||
## LmxProxy v2
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Service Name | ZB.MOM.WW.LmxProxy.Host |
|
||||
| Display Name | SCADA Bridge LMX Proxy |
|
||||
| gRPC Port | 50051 |
|
||||
| Status Page | http://10.100.0.48:8080/ |
|
||||
| Health Endpoint | http://10.100.0.48:8080/api/health |
|
||||
| Publish Directory | C:\publish-v2\ |
|
||||
| API Keys | C:\publish-v2\apikeys.json |
|
||||
| Logs | C:\publish-v2\logs\ |
|
||||
| Protocol | v2 (TypedValue + QualityCode) |
|
||||
```
|
||||
|
||||
### 7.2 Update lmxproxy CLAUDE.md
|
||||
|
||||
If `lmxproxy/CLAUDE.md` references v1 behavior, update:
|
||||
- Change "currently v1 protocol" references to "v2 protocol"
|
||||
- Update publish directory references from `C:\publish\` to `C:\publish-v2\`
|
||||
- Update any value conversion notes (no more string heuristics)
|
||||
|
||||
### 7.3 Clean up v1 publish directory (optional)
|
||||
|
||||
```bash
|
||||
ssh windev "if exist C:\publish\ ren C:\publish publish-v1-backup"
|
||||
```
|
||||
|
||||
## Step 8: Veeam Backup
|
||||
|
||||
### 8.1 Take incremental backup
|
||||
|
||||
```bash
|
||||
ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Start-VBRJob -Job (Get-VBRJob -Name 'Backup WW_DEV_VM')\""
|
||||
```
|
||||
|
||||
### 8.2 Wait for backup to complete (check status)
|
||||
|
||||
```bash
|
||||
ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; (Get-VBRJob -Name 'Backup WW_DEV_VM').FindLastSession() | Select-Object State, Result, CreationTime, EndTime\""
|
||||
```
|
||||
|
||||
Expected: `State: Stopped, Result: Success`.
|
||||
|
||||
### 8.3 Get the restore point ID
|
||||
|
||||
```bash
|
||||
ssh dohertj2@10.100.0.30 "powershell -Command \"Add-PSSnapin VeeamPSSnapin; Connect-VBRServer -Server localhost; Get-VBRRestorePoint -Backup (Get-VBRBackup -Name 'Backup WW_DEV_VM') | Select-Object Id, CreationTime, Type, @{N='SizeGB';E={[math]::Round(\`$_.ApproxSize/1GB,2)}} | Format-Table -AutoSize\""
|
||||
```
|
||||
|
||||
### 8.4 Record in windev.md
|
||||
|
||||
Add a new row to the Restore Points table in `windev.md`:
|
||||
|
||||
```markdown
|
||||
| `XXXXXXXX` | 2026-XX-XX XX:XX | Increment | **Post-v2 deployment** — LmxProxy v2 live on port 50051 |
|
||||
```
|
||||
|
||||
Replace placeholders with actual restore point ID and timestamp.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- [ ] v2 Host binary published to `C:\publish-v2\` on windev
|
||||
- [ ] v2 service installed and running on alternate ports (50052/8081) — verified via status page
|
||||
- [ ] Integration test project created at `tests/ZB.MOM.WW.LmxProxy.Client.IntegrationTests/`
|
||||
- [ ] All integration tests pass against v2 on alternate ports:
|
||||
- [ ] Connect/disconnect lifecycle
|
||||
- [ ] Read string tag `JoeAppEngine.Area` — value "JoeDev", Good quality
|
||||
- [ ] Read writable tag `JoeAppEngine.BTCS`
|
||||
- [ ] Write string then read-back verification
|
||||
- [ ] ReadBatch multiple tags
|
||||
- [ ] Subscribe to `JoeAppEngine.Scheduler.ScanTime` — verify updates received with TypedValue + QualityCode
|
||||
- [ ] WriteBatchAndWait with type-aware flag comparison
|
||||
- [ ] CheckApiKey — valid ReadWrite, valid ReadOnly, invalid
|
||||
- [ ] Write with ReadOnly key — PermissionDenied
|
||||
- [ ] Connect with invalid API key — Unauthenticated
|
||||
- [ ] v1 service stopped and uninstalled
|
||||
- [ ] v2 service reconfigured to production ports (50051/8080) and reinstalled
|
||||
- [ ] All integration tests pass on production ports
|
||||
- [ ] Service recovery configured (restart on failure)
|
||||
- [ ] `windev.md` updated with v2 service details
|
||||
- [ ] `lmxproxy/CLAUDE.md` updated for v2
|
||||
- [ ] Veeam backup taken and restore point ID recorded in `windev.md`
|
||||
- [ ] v1 publish directory backed up or removed
|
||||
Reference in New Issue
Block a user