# 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? _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 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(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 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 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 { /// Registers a singleton ILmxProxyClient from the "LmxProxy" config section. public static IServiceCollection AddLmxProxyClient( this IServiceCollection services, IConfiguration configuration) { return services.AddLmxProxyClient(configuration, "LmxProxy"); } /// Registers a singleton ILmxProxyClient from a named config section. public static IServiceCollection AddLmxProxyClient( this IServiceCollection services, IConfiguration configuration, string sectionName) { services.AddSingleton( sp => new LmxProxyClientFactory(configuration)); services.AddSingleton(sp => { var factory = sp.GetRequiredService(); return factory.CreateClient(sectionName); }); return services; } /// Registers a singleton ILmxProxyClient via builder action. public static IServiceCollection AddLmxProxyClient( this IServiceCollection services, Action configure) { services.AddSingleton(sp => { var builder = new LmxProxyClientBuilder(); configure(builder); return builder.Build(); }); return services; } /// Registers a scoped ILmxProxyClient from the "LmxProxy" config section. public static IServiceCollection AddScopedLmxProxyClient( this IServiceCollection services, IConfiguration configuration) { services.AddSingleton( sp => new LmxProxyClientFactory(configuration)); services.AddScoped(sp => { var factory = sp.GetRequiredService(); return factory.CreateClient(); }); return services; } /// Registers a keyed singleton ILmxProxyClient. public static IServiceCollection AddNamedLmxProxyClient( this IServiceCollection services, string name, Action configure) { services.AddKeyedSingleton(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 { /// /// Reads multiple tags as an async stream in batches. /// Retries up to 2 times per batch. Aborts after 3 consecutive batch errors. /// public static async IAsyncEnumerable> ReadStreamAsync( this ILmxProxyClient client, IEnumerable 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(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> ReadBatchWithRetry( ILmxProxyClient client, List batch, int maxRetries, [EnumeratorCancellation] CancellationToken ct) { int retries = 0; while (retries <= maxRetries) { IDictionary? 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 /// /// Writes values from an async enumerable in batches. Returns total count written. /// public static async Task WriteStreamAsync( this ILmxProxyClient client, IAsyncEnumerable> 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(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 /// /// Processes items in parallel with a configurable max concurrency (default 4). /// public static async Task ProcessInParallelAsync( this IAsyncEnumerable source, Func 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(); 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 /// /// Wraps a callback-based subscription into an IAsyncEnumerable via System.Threading.Channels. /// public static async IAsyncEnumerable<(string Tag, Vtq Vtq)> SubscribeStreamAsync( this ILmxProxyClient client, IEnumerable 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(() => 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(() => new LmxProxyClientBuilder().WithPort(0)); } [Fact] public void WithPort_ThrowsOn65536() { Assert.Throws(() => new LmxProxyClientBuilder().WithPort(65536)); } [Fact] public void WithTimeout_ThrowsOnNegative() { Assert.Throws(() => new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromSeconds(-1))); } [Fact] public void WithTimeout_ThrowsOver10Minutes() { Assert.Throws(() => new LmxProxyClientBuilder().WithTimeout(TimeSpan.FromMinutes(11))); } [Fact] public void WithRetryPolicy_ThrowsOnZeroAttempts() { Assert.Throws(() => new LmxProxyClientBuilder().WithRetryPolicy(0, TimeSpan.FromSeconds(1))); } [Fact] public void WithRetryPolicy_ThrowsOnZeroDelay() { Assert.Throws(() => 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(() => builder.Build()); } [Fact] public void WithHost_ThrowsOnNull() { Assert.Throws(() => new LmxProxyClientBuilder().WithHost(null!)); } [Fact] public void WithHost_ThrowsOnEmpty() { Assert.Throws(() => 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 { ["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 { ["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