deprecate(lmxproxy): move all LmxProxy code, tests, and docs to deprecated/
LmxProxy is no longer needed. Moved the entire lmxproxy/ workspace, DCL adapter files, and related docs to deprecated/. Removed LmxProxy registration from DataConnectionFactory, project reference from DCL, protocol option from UI, and cleaned up all requirement docs.
This commit is contained in:
815
deprecated/lmxproxy/docs/plans/phase-6-client-extras.md
Normal file
815
deprecated/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
|
||||
Reference in New Issue
Block a user