feat(lmxproxy): phase 6 — client extras (builder, factory, DI, streaming extensions)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-22 00:29:16 -04:00
parent 8ba75b50e8
commit 215cfa29f3
12 changed files with 1082 additions and 3 deletions

View File

@@ -3,11 +3,17 @@ namespace ZB.MOM.WW.LmxProxy.Client;
/// <summary>
/// Configuration options for the LmxProxy client, typically set via the builder.
/// </summary>
public class ClientConfiguration
internal class ClientConfiguration
{
/// <summary>Maximum number of retry attempts for transient failures.</summary>
public int MaxRetryAttempts { get; set; } = 0;
public int MaxRetryAttempts { get; set; }
/// <summary>Base delay between retries (exponential backoff applied).</summary>
public TimeSpan RetryDelay { get; set; } = TimeSpan.FromSeconds(1);
public TimeSpan RetryDelay { get; set; }
/// <summary>Whether client-side metrics collection is enabled.</summary>
public bool EnableMetrics { get; set; }
/// <summary>Optional header name for correlation ID propagation.</summary>
public string? CorrelationIdHeader { get; set; }
}

View File

@@ -0,0 +1,81 @@
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.LmxProxy.Client;
/// <summary>
/// Factory for creating <see cref="LmxProxyClient"/> instances.
/// </summary>
public interface ILmxProxyClientFactory
{
/// <summary>Creates a client from the default "LmxProxy" configuration section.</summary>
LmxProxyClient CreateClient();
/// <summary>Creates a client from a named configuration section.</summary>
LmxProxyClient CreateClient(string configName);
/// <summary>Creates a client using a builder configuration action.</summary>
LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction);
}
/// <summary>
/// Default implementation of <see cref="ILmxProxyClientFactory"/> that reads from IConfiguration.
/// </summary>
public class LmxProxyClientFactory : ILmxProxyClientFactory
{
private readonly IConfiguration _configuration;
/// <summary>Creates a new factory with the specified configuration.</summary>
public LmxProxyClientFactory(IConfiguration configuration)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
}
/// <inheritdoc />
public LmxProxyClient CreateClient() => CreateClient("LmxProxy");
/// <inheritdoc />
public LmxProxyClient CreateClient(string configName)
{
IConfigurationSection section = _configuration.GetSection(configName);
var options = new LmxProxyClientOptions();
section.Bind(options);
return BuildFromOptions(options);
}
/// <inheritdoc />
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();
}
}

View File

@@ -0,0 +1,157 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.LmxProxy.Client;
/// <summary>
/// Fluent builder for creating configured <see cref="LmxProxyClient"/> instances.
/// </summary>
public class LmxProxyClientBuilder
{
private string? _host;
private int _port = 50051;
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;
/// <summary>Sets the host address of the LmxProxy server. Required.</summary>
public LmxProxyClientBuilder WithHost(string host)
{
if (string.IsNullOrWhiteSpace(host))
throw new ArgumentException("Host must not be null or empty.", nameof(host));
_host = host;
return this;
}
/// <summary>Sets the port of the LmxProxy server. Default is 50051.</summary>
public LmxProxyClientBuilder WithPort(int port)
{
if (port < 1 || port > 65535)
throw new ArgumentOutOfRangeException(nameof(port), "Port must be between 1 and 65535.");
_port = port;
return this;
}
/// <summary>Sets the API key for authentication.</summary>
public LmxProxyClientBuilder WithApiKey(string? apiKey)
{
_apiKey = apiKey;
return this;
}
/// <summary>Sets the logger instance for the client.</summary>
public LmxProxyClientBuilder WithLogger(ILogger<LmxProxyClient> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
return this;
}
/// <summary>Sets the default timeout for operations. Must be between 1 second and 10 minutes.</summary>
public LmxProxyClientBuilder WithTimeout(TimeSpan timeout)
{
if (timeout <= TimeSpan.Zero || timeout > TimeSpan.FromMinutes(10))
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be greater than zero and at most 10 minutes.");
_defaultTimeout = timeout;
return this;
}
/// <summary>Enables TLS with an optional server CA certificate path.</summary>
public LmxProxyClientBuilder WithSslCredentials(string? certificatePath)
{
_tlsConfiguration ??= new ClientTlsConfiguration();
_tlsConfiguration.UseTls = true;
_tlsConfiguration.ServerCaCertificatePath = certificatePath;
return this;
}
/// <summary>Sets a full TLS configuration.</summary>
public LmxProxyClientBuilder WithTlsConfiguration(ClientTlsConfiguration config)
{
_tlsConfiguration = config ?? throw new ArgumentNullException(nameof(config));
return this;
}
/// <summary>Configures the retry policy. maxAttempts must be positive, retryDelay must be positive.</summary>
public LmxProxyClientBuilder WithRetryPolicy(int maxAttempts, TimeSpan retryDelay)
{
if (maxAttempts <= 0)
throw new ArgumentOutOfRangeException(nameof(maxAttempts), "Max retry attempts must be greater than zero.");
if (retryDelay <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(retryDelay), "Retry delay must be greater than zero.");
_maxRetryAttempts = maxAttempts;
_retryDelay = retryDelay;
return this;
}
/// <summary>Enables client-side metrics collection.</summary>
public LmxProxyClientBuilder WithMetrics()
{
_enableMetrics = true;
return this;
}
/// <summary>Sets the correlation ID header name for request tracing.</summary>
public LmxProxyClientBuilder WithCorrelationIdHeader(string headerName)
{
if (string.IsNullOrEmpty(headerName))
throw new ArgumentException("Header name must not be null or empty.", nameof(headerName));
_correlationIdHeader = headerName;
return this;
}
/// <summary>
/// Builds and returns a configured <see cref="LmxProxyClient"/> instance.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when host is not set.</exception>
/// <exception cref="FileNotFoundException">Thrown when TLS certificate paths don't exist.</exception>
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;
}
private void ValidateTlsConfiguration()
{
if (_tlsConfiguration?.UseTls != true)
return;
if (!string.IsNullOrEmpty(_tlsConfiguration.ServerCaCertificatePath) &&
!File.Exists(_tlsConfiguration.ServerCaCertificatePath))
throw new FileNotFoundException(
$"Server CA certificate not found: {_tlsConfiguration.ServerCaCertificatePath}",
_tlsConfiguration.ServerCaCertificatePath);
if (!string.IsNullOrEmpty(_tlsConfiguration.ClientCertificatePath) &&
!File.Exists(_tlsConfiguration.ClientCertificatePath))
throw new FileNotFoundException(
$"Client certificate not found: {_tlsConfiguration.ClientCertificatePath}",
_tlsConfiguration.ClientCertificatePath);
if (!string.IsNullOrEmpty(_tlsConfiguration.ClientKeyPath) &&
!File.Exists(_tlsConfiguration.ClientKeyPath))
throw new FileNotFoundException(
$"Client key not found: {_tlsConfiguration.ClientKeyPath}",
_tlsConfiguration.ClientKeyPath);
}
}

View File

@@ -0,0 +1,46 @@
namespace ZB.MOM.WW.LmxProxy.Client;
/// <summary>
/// Configuration options for creating an LmxProxy client from IConfiguration sections.
/// </summary>
public class LmxProxyClientOptions
{
/// <summary>Host address of the LmxProxy server.</summary>
public string Host { get; set; } = "localhost";
/// <summary>Port of the LmxProxy server.</summary>
public int Port { get; set; } = 50051;
/// <summary>API key for authentication.</summary>
public string? ApiKey { get; set; }
/// <summary>Default timeout for operations.</summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Whether to use TLS for the connection.</summary>
public bool UseSsl { get; set; }
/// <summary>Path to the server CA certificate for TLS.</summary>
public string? CertificatePath { get; set; }
/// <summary>Whether to enable client-side metrics collection.</summary>
public bool EnableMetrics { get; set; }
/// <summary>Optional header name for correlation ID propagation.</summary>
public string? CorrelationIdHeader { get; set; }
/// <summary>Retry policy options.</summary>
public RetryOptions Retry { get; set; } = new();
}
/// <summary>
/// Retry policy configuration options.
/// </summary>
public class RetryOptions
{
/// <summary>Maximum number of retry attempts.</summary>
public int MaxAttempts { get; set; } = 3;
/// <summary>Base delay between retries.</summary>
public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1);
}

View File

@@ -0,0 +1,71 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.LmxProxy.Client;
/// <summary>
/// Extension methods for registering LmxProxy client services in the DI container.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,218 @@
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client;
/// <summary>
/// Extension methods for streaming reads, writes, and subscriptions over ILmxProxyClient.
/// </summary>
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)
{
bool success = false;
await foreach (var kvp in ReadBatchWithRetry(
client, batch, maxRetries, cancellationToken))
{
consecutiveErrors = 0;
success = true;
yield return kvp;
}
if (!success)
{
consecutiveErrors++;
if (consecutiveErrors >= maxConsecutiveErrors)
yield break;
}
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++;
}
}
/// <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;
}
/// <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);
}
/// <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
});
LmxProxyClient.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();
}
}
}