feat(lmxproxy): phase 1 — v2 protocol types and domain model

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-21 23:41:56 -04:00
parent 08d2a07d8b
commit 0d63fb1105
87 changed files with 3389 additions and 956 deletions

View File

@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Health check service for monitoring LmxProxy health
/// </summary>
public class HealthCheckService : IHealthCheck
{
private static readonly ILogger Logger = Log.ForContext<HealthCheckService>();
private readonly PerformanceMetrics _performanceMetrics;
private readonly IScadaClient _scadaClient;
private readonly SubscriptionManager _subscriptionManager;
public HealthCheckService(
IScadaClient scadaClient,
SubscriptionManager subscriptionManager,
PerformanceMetrics performanceMetrics)
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
_subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager));
_performanceMetrics = performanceMetrics ?? throw new ArgumentNullException(nameof(performanceMetrics));
}
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var data = new Dictionary<string, object>();
try
{
// Check SCADA connection
bool isConnected = _scadaClient.IsConnected;
ConnectionState connectionState = _scadaClient.ConnectionState;
data["scada_connected"] = isConnected;
data["scada_connection_state"] = connectionState.ToString();
// Get subscription statistics
SubscriptionStats subscriptionStats = _subscriptionManager.GetSubscriptionStats();
data["total_clients"] = subscriptionStats.TotalClients;
data["total_tags"] = subscriptionStats.TotalTags;
// Get performance metrics
IReadOnlyDictionary<string, OperationMetrics> metrics = _performanceMetrics.GetAllMetrics();
long totalOperations = 0L;
double averageSuccessRate = 0.0;
foreach (OperationMetrics? metric in metrics.Values)
{
MetricsStatistics stats = metric.GetStatistics();
totalOperations += stats.TotalCount;
averageSuccessRate += stats.SuccessRate;
}
if (metrics.Count > 0)
{
averageSuccessRate /= metrics.Count;
}
data["total_operations"] = totalOperations;
data["average_success_rate"] = averageSuccessRate;
// Determine health status
if (!isConnected)
{
return Task.FromResult(HealthCheckResult.Unhealthy(
"SCADA client is not connected",
data: data));
}
if (averageSuccessRate < 0.5 && totalOperations > 100)
{
return Task.FromResult(HealthCheckResult.Degraded(
$"Low success rate: {averageSuccessRate:P}",
data: data));
}
if (subscriptionStats.TotalClients > 100)
{
return Task.FromResult(HealthCheckResult.Degraded(
$"High client count: {subscriptionStats.TotalClients}",
data: data));
}
return Task.FromResult(HealthCheckResult.Healthy(
"LmxProxy is healthy",
data));
}
catch (Exception ex)
{
Logger.Error(ex, "Health check failed");
data["error"] = ex.Message;
return Task.FromResult(HealthCheckResult.Unhealthy(
"Health check threw an exception",
ex,
data));
}
}
}
/// <summary>
/// Detailed health check that performs additional connectivity tests
/// </summary>
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")
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
_testTagAddress = testTagAddress;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var data = new Dictionary<string, object>();
try
{
// Basic connectivity check
if (!_scadaClient.IsConnected)
{
data["connected"] = false;
return HealthCheckResult.Unhealthy("SCADA client is not connected", data: data);
}
data["connected"] = true;
// Try to read a test tag
try
{
Vtq vtq = await _scadaClient.ReadAsync(_testTagAddress, cancellationToken);
data["test_tag_quality"] = vtq.Quality.ToString();
data["test_tag_timestamp"] = vtq.Timestamp;
if (vtq.Quality != Quality.Good)
{
return HealthCheckResult.Degraded(
$"Test tag quality is {vtq.Quality}",
data: data);
}
// Check if timestamp is recent (within last 5 minutes)
TimeSpan age = DateTime.UtcNow - vtq.Timestamp;
if (age > TimeSpan.FromMinutes(5))
{
data["timestamp_age_minutes"] = age.TotalMinutes;
return HealthCheckResult.Degraded(
$"Test tag timestamp is stale ({age.TotalMinutes:F1} minutes old)",
data: data);
}
}
catch (Exception readEx)
{
data["test_tag_error"] = readEx.Message;
return HealthCheckResult.Degraded(
"Could not read test tag",
data: data);
}
return HealthCheckResult.Healthy("All checks passed", data);
}
catch (Exception ex)
{
Logger.Error(ex, "Detailed health check failed");
data["error"] = ex.Message;
return HealthCheckResult.Unhealthy(
"Health check threw an exception",
ex,
data);
}
}
}
}

View File

@@ -0,0 +1,213 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Provides performance metrics tracking for LmxProxy operations
/// </summary>
public class PerformanceMetrics : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
private readonly ConcurrentDictionary<string, OperationMetrics> _metrics = new();
private readonly Timer _reportingTimer;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the PerformanceMetrics class
/// </summary>
public PerformanceMetrics()
{
// Report metrics every minute
_reportingTimer = new Timer(ReportMetrics, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_reportingTimer?.Dispose();
ReportMetrics(null); // Final report
}
/// <summary>
/// Records the execution time of an operation
/// </summary>
public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
{
OperationMetrics? metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
metrics.Record(duration, success);
}
/// <summary>
/// Creates a timing scope for measuring operation duration
/// </summary>
public ITimingScope BeginOperation(string operationName) => new TimingScope(this, operationName);
/// <summary>
/// Gets current metrics for a specific operation
/// </summary>
public OperationMetrics? GetMetrics(string operationName) =>
_metrics.TryGetValue(operationName, out OperationMetrics? metrics) ? metrics : null;
/// <summary>
/// Gets all current metrics
/// </summary>
public IReadOnlyDictionary<string, OperationMetrics> GetAllMetrics() =>
_metrics.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
/// <summary>
/// Gets statistics for all operations
/// </summary>
public Dictionary<string, MetricsStatistics> GetStatistics() =>
_metrics.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.GetStatistics());
private void ReportMetrics(object? state)
{
foreach (KeyValuePair<string, OperationMetrics> kvp in _metrics)
{
MetricsStatistics stats = kvp.Value.GetStatistics();
if (stats.TotalCount > 0)
{
Logger.Information(
"Performance Metrics - {Operation}: Count={Count}, Success={SuccessRate:P}, " +
"Avg={AverageMs:F2}ms, Min={MinMs:F2}ms, Max={MaxMs:F2}ms, P95={P95Ms:F2}ms",
kvp.Key,
stats.TotalCount,
stats.SuccessRate,
stats.AverageMilliseconds,
stats.MinMilliseconds,
stats.MaxMilliseconds,
stats.Percentile95Milliseconds);
}
}
}
/// <summary>
/// Timing scope for automatic duration measurement
/// </summary>
public interface ITimingScope : IDisposable
{
void SetSuccess(bool success);
}
private class TimingScope : ITimingScope
{
private readonly PerformanceMetrics _metrics;
private readonly string _operationName;
private readonly Stopwatch _stopwatch;
private bool _disposed;
private bool _success = true;
public TimingScope(PerformanceMetrics metrics, string operationName)
{
_metrics = metrics;
_operationName = operationName;
_stopwatch = Stopwatch.StartNew();
}
public void SetSuccess(bool success) => _success = success;
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_stopwatch.Stop();
_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success);
}
}
}
/// <summary>
/// Metrics for a specific operation
/// </summary>
public class OperationMetrics
{
private readonly List<double> _durations = new();
private readonly object _lock = new();
private double _maxMilliseconds;
private double _minMilliseconds = double.MaxValue;
private long _successCount;
private long _totalCount;
private double _totalMilliseconds;
public void Record(TimeSpan duration, bool success)
{
lock (_lock)
{
double ms = duration.TotalMilliseconds;
_durations.Add(ms);
_totalCount++;
if (success)
{
_successCount++;
}
_totalMilliseconds += ms;
_minMilliseconds = Math.Min(_minMilliseconds, ms);
_maxMilliseconds = Math.Max(_maxMilliseconds, ms);
// Keep only last 1000 samples for percentile calculation
if (_durations.Count > 1000)
{
_durations.RemoveAt(0);
}
}
}
public MetricsStatistics GetStatistics()
{
lock (_lock)
{
if (_totalCount == 0)
{
return new MetricsStatistics();
}
var sortedDurations = _durations.OrderBy(d => d).ToList();
int p95Index = (int)Math.Ceiling(sortedDurations.Count * 0.95) - 1;
return new MetricsStatistics
{
TotalCount = _totalCount,
SuccessCount = _successCount,
SuccessRate = _successCount / (double)_totalCount,
AverageMilliseconds = _totalMilliseconds / _totalCount,
MinMilliseconds = _minMilliseconds == double.MaxValue ? 0 : _minMilliseconds,
MaxMilliseconds = _maxMilliseconds,
Percentile95Milliseconds = sortedDurations.Count > 0 ? sortedDurations[Math.Max(0, p95Index)] : 0
};
}
}
}
/// <summary>
/// Statistics for an operation
/// </summary>
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; }
}
}

View File

@@ -0,0 +1,193 @@
using System;
using System.Threading.Tasks;
using Polly;
using Polly.Timeout;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Provides retry policies for resilient operations
/// </summary>
public static class RetryPolicies
{
private static readonly ILogger Logger = Log.ForContext(typeof(RetryPolicies));
/// <summary>
/// Creates a retry policy with exponential backoff for read operations
/// </summary>
public static IAsyncPolicy<T> CreateReadPolicy<T>()
{
return Policy<T>
.Handle<Exception>(ex => !(ex is ArgumentException || ex is InvalidOperationException))
.WaitAndRetryAsync(
3,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt - 1)),
(outcome, timespan, retryCount, context) =>
{
Exception? exception = outcome.Exception;
Logger.Warning(exception,
"Read operation retry {RetryCount} after {DelayMs}ms. Operation: {Operation}",
retryCount,
timespan.TotalMilliseconds,
context.ContainsKey("Operation") ? context["Operation"] : "Unknown");
});
}
/// <summary>
/// Creates a retry policy with exponential backoff for write operations
/// </summary>
public static IAsyncPolicy CreateWritePolicy()
{
return Policy
.Handle<Exception>(ex => !(ex is ArgumentException || ex is InvalidOperationException))
.WaitAndRetryAsync(
3,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
(exception, timespan, retryCount, context) =>
{
Logger.Warning(exception,
"Write operation retry {RetryCount} after {DelayMs}ms. Operation: {Operation}",
retryCount,
timespan.TotalMilliseconds,
context.ContainsKey("Operation") ? context["Operation"] : "Unknown");
});
}
/// <summary>
/// Creates a retry policy for connection operations with longer delays
/// </summary>
public static IAsyncPolicy CreateConnectionPolicy()
{
return Policy
.Handle<Exception>()
.WaitAndRetryAsync(
5,
retryAttempt =>
{
// 2s, 4s, 8s, 16s, 32s
var delay = TimeSpan.FromSeconds(Math.Min(32, Math.Pow(2, retryAttempt)));
return delay;
},
(exception, timespan, retryCount, context) =>
{
Logger.Warning(exception,
"Connection retry {RetryCount} after {DelayMs}ms",
retryCount,
timespan.TotalMilliseconds);
});
}
/// <summary>
/// Creates a circuit breaker policy for protecting against repeated failures
/// </summary>
public static IAsyncPolicy<T> CreateCircuitBreakerPolicy<T>()
{
return Policy<T>
.Handle<Exception>()
.CircuitBreakerAsync(
5,
TimeSpan.FromSeconds(30),
(result, timespan) =>
{
Logger.Error(result.Exception,
"Circuit breaker opened for {BreakDurationSeconds}s due to repeated failures",
timespan.TotalSeconds);
},
() => { Logger.Information("Circuit breaker reset - resuming normal operations"); },
() => { Logger.Information("Circuit breaker half-open - testing operation"); });
}
/// <summary>
/// Creates a combined policy with retry and circuit breaker
/// </summary>
public static IAsyncPolicy<T> CreateCombinedPolicy<T>()
{
IAsyncPolicy<T> retry = CreateReadPolicy<T>();
IAsyncPolicy<T> circuitBreaker = CreateCircuitBreakerPolicy<T>();
// Wrap retry around circuit breaker
// This means retry happens first, and if all retries fail, it counts toward the circuit breaker
return Policy.WrapAsync(retry, circuitBreaker);
}
/// <summary>
/// Creates a timeout policy for operations
/// </summary>
public static IAsyncPolicy CreateTimeoutPolicy(TimeSpan timeout)
{
return Policy
.TimeoutAsync(
timeout,
TimeoutStrategy.Pessimistic,
async (context, timespan, task) =>
{
Logger.Warning(
"Operation timed out after {TimeoutMs}ms. Operation: {Operation}",
timespan.TotalMilliseconds,
context.ContainsKey("Operation") ? context["Operation"] : "Unknown");
if (task != null)
{
try
{
await task;
}
catch
{
// Ignore exceptions from the timed-out task
}
}
});
}
/// <summary>
/// Creates a bulkhead policy to limit concurrent operations
/// </summary>
public static IAsyncPolicy CreateBulkheadPolicy(int maxParallelization, int maxQueuingActions = 100)
{
return Policy
.BulkheadAsync(
maxParallelization,
maxQueuingActions,
context =>
{
Logger.Warning(
"Bulkhead rejected operation. Max parallelization: {MaxParallel}, Queue: {MaxQueue}",
maxParallelization,
maxQueuingActions);
return Task.CompletedTask;
});
}
}
/// <summary>
/// Extension methods for applying retry policies
/// </summary>
public static class RetryPolicyExtensions
{
/// <summary>
/// Executes an operation with retry policy
/// </summary>
public static async Task<T> ExecuteWithRetryAsync<T>(
this IAsyncPolicy<T> policy,
Func<Task<T>> operation,
string operationName)
{
var context = new Context { ["Operation"] = operationName };
return await policy.ExecuteAsync(async ctx => await operation(), context);
}
/// <summary>
/// Executes an operation with retry policy (non-generic)
/// </summary>
public static async Task ExecuteWithRetryAsync(
this IAsyncPolicy policy,
Func<Task> operation,
string operationName)
{
var context = new Context { ["Operation"] = operationName };
await policy.ExecuteAsync(async ctx => await operation(), context);
}
}
}

View File

@@ -0,0 +1,182 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Manages client sessions for the gRPC service.
/// Tracks active sessions with unique session IDs.
/// </summary>
public class SessionManager : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<SessionManager>();
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
private bool _disposed;
/// <summary>
/// Gets the number of active sessions.
/// </summary>
public int ActiveSessionCount => _sessions.Count;
/// <summary>
/// Creates a new session for a client.
/// </summary>
/// <param name="clientId">The client identifier.</param>
/// <param name="apiKey">The API key used for authentication (optional).</param>
/// <returns>The session ID for the new session.</returns>
/// <exception cref="ObjectDisposedException">Thrown if the manager is disposed.</exception>
public string CreateSession(string clientId, string apiKey = null)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SessionManager));
}
var sessionId = Guid.NewGuid().ToString("N");
var sessionInfo = new SessionInfo
{
SessionId = sessionId,
ClientId = clientId ?? string.Empty,
ApiKey = apiKey ?? string.Empty,
ConnectedAt = DateTime.UtcNow,
LastActivity = DateTime.UtcNow
};
_sessions[sessionId] = sessionInfo;
Logger.Information("Created session {SessionId} for client {ClientId}", sessionId, clientId);
return sessionId;
}
/// <summary>
/// Validates a session ID and updates the last activity timestamp.
/// </summary>
/// <param name="sessionId">The session ID to validate.</param>
/// <returns>True if the session is valid; otherwise, false.</returns>
public bool ValidateSession(string sessionId)
{
if (_disposed)
{
return false;
}
if (string.IsNullOrEmpty(sessionId))
{
return false;
}
if (_sessions.TryGetValue(sessionId, out SessionInfo sessionInfo))
{
sessionInfo.LastActivity = DateTime.UtcNow;
return true;
}
return false;
}
/// <summary>
/// Gets the session information for a session ID.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>The session information, or null if not found.</returns>
public SessionInfo GetSession(string sessionId)
{
if (_disposed || string.IsNullOrEmpty(sessionId))
{
return null;
}
_sessions.TryGetValue(sessionId, out SessionInfo sessionInfo);
return sessionInfo;
}
/// <summary>
/// Terminates a session.
/// </summary>
/// <param name="sessionId">The session ID to terminate.</param>
/// <returns>True if the session was terminated; otherwise, false.</returns>
public bool TerminateSession(string sessionId)
{
if (_disposed || string.IsNullOrEmpty(sessionId))
{
return false;
}
if (_sessions.TryRemove(sessionId, out SessionInfo sessionInfo))
{
Logger.Information("Terminated session {SessionId} for client {ClientId}", sessionId, sessionInfo.ClientId);
return true;
}
return false;
}
/// <summary>
/// Gets all active sessions.
/// </summary>
/// <returns>A list of all active session information.</returns>
public IReadOnlyList<SessionInfo> GetAllSessions()
{
return _sessions.Values.ToList();
}
/// <summary>
/// Disposes the session manager and clears all sessions.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
var count = _sessions.Count;
_sessions.Clear();
Logger.Information("SessionManager disposed, cleared {Count} sessions", count);
}
}
/// <summary>
/// Contains information about a client session.
/// </summary>
public class SessionInfo
{
/// <summary>
/// Gets or sets the unique session identifier.
/// </summary>
public string SessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the client identifier.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the API key used for this session.
/// </summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the time when the session was created.
/// </summary>
public DateTime ConnectedAt { get; set; }
/// <summary>
/// Gets or sets the time of the last activity on this session.
/// </summary>
public DateTime LastActivity { get; set; }
/// <summary>
/// Gets the connected time as UTC ticks for the gRPC response.
/// </summary>
public long ConnectedSinceUtcTicks => ConnectedAt.Ticks;
}
}

View File

@@ -0,0 +1,433 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Service for collecting and formatting status information from various LmxProxy components
/// </summary>
public class StatusReportService
{
private static readonly ILogger Logger = Log.ForContext<StatusReportService>();
private readonly DetailedHealthCheckService? _detailedHealthCheckService;
private readonly HealthCheckService _healthCheckService;
private readonly PerformanceMetrics _performanceMetrics;
private readonly IScadaClient _scadaClient;
private readonly SubscriptionManager _subscriptionManager;
/// <summary>
/// Initializes a new instance of the StatusReportService class
/// </summary>
public StatusReportService(
IScadaClient scadaClient,
SubscriptionManager subscriptionManager,
PerformanceMetrics performanceMetrics,
HealthCheckService healthCheckService,
DetailedHealthCheckService? detailedHealthCheckService = null)
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
_subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager));
_performanceMetrics = performanceMetrics ?? throw new ArgumentNullException(nameof(performanceMetrics));
_healthCheckService = healthCheckService ?? throw new ArgumentNullException(nameof(healthCheckService));
_detailedHealthCheckService = detailedHealthCheckService;
}
/// <summary>
/// Generates a comprehensive status report as HTML
/// </summary>
public async Task<string> GenerateHtmlReportAsync()
{
try
{
StatusData statusData = await CollectStatusDataAsync();
return GenerateHtmlFromStatusData(statusData);
}
catch (Exception ex)
{
Logger.Error(ex, "Error generating HTML status report");
return GenerateErrorHtml(ex);
}
}
/// <summary>
/// Generates a comprehensive status report as JSON
/// </summary>
public async Task<string> GenerateJsonReportAsync()
{
try
{
StatusData statusData = await CollectStatusDataAsync();
return JsonSerializer.Serialize(statusData, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
catch (Exception ex)
{
Logger.Error(ex, "Error generating JSON status report");
return JsonSerializer.Serialize(new { error = ex.Message }, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
}
/// <summary>
/// Checks if the service is healthy
/// </summary>
public async Task<bool> IsHealthyAsync()
{
try
{
HealthCheckResult healthResult = await _healthCheckService.CheckHealthAsync(new HealthCheckContext());
return healthResult.Status == HealthStatus.Healthy;
}
catch (Exception ex)
{
Logger.Error(ex, "Error checking health status");
return false;
}
}
/// <summary>
/// Collects status data from all components
/// </summary>
private async Task<StatusData> CollectStatusDataAsync()
{
var statusData = new StatusData
{
Timestamp = DateTime.UtcNow,
ServiceName = "ZB.MOM.WW.LmxProxy.Host",
Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown"
};
// Collect connection status
statusData.Connection = new ConnectionStatus
{
IsConnected = _scadaClient.IsConnected,
State = _scadaClient.ConnectionState.ToString(),
NodeName = "N/A", // Could be extracted from configuration if needed
GalaxyName = "N/A" // Could be extracted from configuration if needed
};
// Collect subscription statistics
SubscriptionStats subscriptionStats = _subscriptionManager.GetSubscriptionStats();
statusData.Subscriptions = new SubscriptionStatus
{
TotalClients = subscriptionStats.TotalClients,
TotalTags = subscriptionStats.TotalTags,
ActiveSubscriptions = subscriptionStats.TotalTags // Assuming same for simplicity
};
// Collect performance metrics
Dictionary<string, MetricsStatistics> perfMetrics = _performanceMetrics.GetStatistics();
statusData.Performance = new PerformanceStatus
{
TotalOperations = perfMetrics.Values.Sum(m => m.TotalCount),
AverageSuccessRate = perfMetrics.Count > 0 ? perfMetrics.Values.Average(m => m.SuccessRate) : 1.0,
Operations = perfMetrics.ToDictionary(
kvp => kvp.Key,
kvp => new OperationStatus
{
TotalCount = kvp.Value.TotalCount,
SuccessRate = kvp.Value.SuccessRate,
AverageMilliseconds = kvp.Value.AverageMilliseconds,
MinMilliseconds = kvp.Value.MinMilliseconds,
MaxMilliseconds = kvp.Value.MaxMilliseconds
})
};
// Collect health check results
try
{
HealthCheckResult healthResult = await _healthCheckService.CheckHealthAsync(new HealthCheckContext());
statusData.Health = new HealthInfo
{
Status = healthResult.Status.ToString(),
Description = healthResult.Description ?? "",
Data = healthResult.Data?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "") ??
new Dictionary<string, string>()
};
// Collect detailed health check if available
if (_detailedHealthCheckService != null)
{
HealthCheckResult detailedHealthResult =
await _detailedHealthCheckService.CheckHealthAsync(new HealthCheckContext());
statusData.DetailedHealth = new HealthInfo
{
Status = detailedHealthResult.Status.ToString(),
Description = detailedHealthResult.Description ?? "",
Data = detailedHealthResult.Data?.ToDictionary(kvp => kvp.Key,
kvp => kvp.Value?.ToString() ?? "") ?? new Dictionary<string, string>()
};
}
}
catch (Exception ex)
{
Logger.Error(ex, "Error collecting health check data");
statusData.Health = new HealthInfo
{
Status = "Error",
Description = $"Health check failed: {ex.Message}",
Data = new Dictionary<string, string>()
};
}
return statusData;
}
/// <summary>
/// Generates HTML from status data
/// </summary>
private static string GenerateHtmlFromStatusData(StatusData statusData)
{
var html = new StringBuilder();
html.AppendLine("<!DOCTYPE html>");
html.AppendLine("<html>");
html.AppendLine("<head>");
html.AppendLine(" <title>LmxProxy Status</title>");
html.AppendLine(" <meta charset=\"utf-8\">");
html.AppendLine(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
html.AppendLine(" <meta http-equiv=\"refresh\" content=\"30\">");
html.AppendLine(" <style>");
html.AppendLine(
" body { font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }");
html.AppendLine(
" .container { max-width: 1200px; margin: 0 auto; background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }");
html.AppendLine(" .header { text-align: center; margin-bottom: 30px; }");
html.AppendLine(
" .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; }");
html.AppendLine(
" .status-card { background: #f9f9f9; padding: 15px; border-radius: 6px; border-left: 4px solid #007acc; }");
html.AppendLine(" .status-card h3 { margin-top: 0; color: #333; }");
html.AppendLine(" .status-value { font-weight: bold; color: #007acc; }");
html.AppendLine(" .status-healthy { color: #28a745; }");
html.AppendLine(" .status-warning { color: #ffc107; }");
html.AppendLine(" .status-error { color: #dc3545; }");
html.AppendLine(" .status-connected { border-left-color: #28a745; }");
html.AppendLine(" .status-disconnected { border-left-color: #dc3545; }");
html.AppendLine(" table { width: 100%; border-collapse: collapse; margin-top: 10px; }");
html.AppendLine(" th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }");
html.AppendLine(" th { background-color: #f2f2f2; }");
html.AppendLine(
" .timestamp { text-align: center; margin-top: 20px; color: #666; font-size: 0.9em; }");
html.AppendLine(" </style>");
html.AppendLine("</head>");
html.AppendLine("<body>");
html.AppendLine(" <div class=\"container\">");
// Header
html.AppendLine(" <div class=\"header\">");
html.AppendLine(" <h1>LmxProxy Status Dashboard</h1>");
html.AppendLine($" <p>Service: {statusData.ServiceName} | Version: {statusData.Version}</p>");
html.AppendLine(" </div>");
html.AppendLine(" <div class=\"status-grid\">");
// Connection Status Card
string connectionClass = statusData.Connection.IsConnected ? "status-connected" : "status-disconnected";
string connectionStatusText = statusData.Connection.IsConnected ? "Connected" : "Disconnected";
string connectionStatusClass = statusData.Connection.IsConnected ? "status-healthy" : "status-error";
html.AppendLine($" <div class=\"status-card {connectionClass}\">");
html.AppendLine(" <h3>MxAccess Connection</h3>");
html.AppendLine(
$" <p>Status: <span class=\"status-value {connectionStatusClass}\">{connectionStatusText}</span></p>");
html.AppendLine(
$" <p>State: <span class=\"status-value\">{statusData.Connection.State}</span></p>");
html.AppendLine(" </div>");
// Subscription Status Card
html.AppendLine(" <div class=\"status-card\">");
html.AppendLine(" <h3>Subscriptions</h3>");
html.AppendLine(
$" <p>Total Clients: <span class=\"status-value\">{statusData.Subscriptions.TotalClients}</span></p>");
html.AppendLine(
$" <p>Total Tags: <span class=\"status-value\">{statusData.Subscriptions.TotalTags}</span></p>");
html.AppendLine(
$" <p>Active Subscriptions: <span class=\"status-value\">{statusData.Subscriptions.ActiveSubscriptions}</span></p>");
html.AppendLine(" </div>");
// Performance Status Card
html.AppendLine(" <div class=\"status-card\">");
html.AppendLine(" <h3>Performance</h3>");
html.AppendLine(
$" <p>Total Operations: <span class=\"status-value\">{statusData.Performance.TotalOperations:N0}</span></p>");
html.AppendLine(
$" <p>Success Rate: <span class=\"status-value\">{statusData.Performance.AverageSuccessRate:P2}</span></p>");
html.AppendLine(" </div>");
// Health Status Card
string healthStatusClass = statusData.Health.Status.ToLowerInvariant() switch
{
"healthy" => "status-healthy",
"degraded" => "status-warning",
_ => "status-error"
};
html.AppendLine(" <div class=\"status-card\">");
html.AppendLine(" <h3>Health Status</h3>");
html.AppendLine(
$" <p>Status: <span class=\"status-value {healthStatusClass}\">{statusData.Health.Status}</span></p>");
html.AppendLine(
$" <p>Description: <span class=\"status-value\">{statusData.Health.Description}</span></p>");
html.AppendLine(" </div>");
html.AppendLine(" </div>");
// Performance Metrics Table
if (statusData.Performance.Operations.Any())
{
html.AppendLine(" <div class=\"status-card\" style=\"margin-top: 20px;\">");
html.AppendLine(" <h3>Operation Performance Metrics</h3>");
html.AppendLine(" <table>");
html.AppendLine(" <tr>");
html.AppendLine(" <th>Operation</th>");
html.AppendLine(" <th>Count</th>");
html.AppendLine(" <th>Success Rate</th>");
html.AppendLine(" <th>Avg (ms)</th>");
html.AppendLine(" <th>Min (ms)</th>");
html.AppendLine(" <th>Max (ms)</th>");
html.AppendLine(" </tr>");
foreach (KeyValuePair<string, OperationStatus> operation in statusData.Performance.Operations)
{
html.AppendLine(" <tr>");
html.AppendLine($" <td>{operation.Key}</td>");
html.AppendLine($" <td>{operation.Value.TotalCount:N0}</td>");
html.AppendLine($" <td>{operation.Value.SuccessRate:P2}</td>");
html.AppendLine($" <td>{operation.Value.AverageMilliseconds:F2}</td>");
html.AppendLine($" <td>{operation.Value.MinMilliseconds:F2}</td>");
html.AppendLine($" <td>{operation.Value.MaxMilliseconds:F2}</td>");
html.AppendLine(" </tr>");
}
html.AppendLine(" </table>");
html.AppendLine(" </div>");
}
// Timestamp
html.AppendLine(
$" <div class=\"timestamp\">Last updated: {statusData.Timestamp:yyyy-MM-dd HH:mm:ss} UTC</div>");
html.AppendLine(" </div>");
html.AppendLine("</body>");
html.AppendLine("</html>");
return html.ToString();
}
/// <summary>
/// Generates error HTML when status collection fails
/// </summary>
private static string GenerateErrorHtml(Exception ex)
{
return $@"<!DOCTYPE html>
<html>
<head>
<title>LmxProxy Status - Error</title>
<meta charset=""utf-8"">
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }}
.container {{ max-width: 800px; margin: 0 auto; background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
.error {{ color: #dc3545; background-color: #f8d7da; padding: 15px; border-radius: 6px; border: 1px solid #f5c6cb; }}
</style>
</head>
<body>
<div class=""container"">
<h1>LmxProxy Status Dashboard</h1>
<div class=""error"">
<h3>Error Loading Status</h3>
<p>An error occurred while collecting status information:</p>
<p><strong>{ex.Message}</strong></p>
</div>
<div style=""text-align: center; margin-top: 20px; color: #666; font-size: 0.9em;"">
Last updated: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
</div>
</div>
</body>
</html>";
}
}
/// <summary>
/// Data structure for holding complete status information
/// </summary>
public class StatusData
{
public DateTime Timestamp { get; set; }
public string ServiceName { get; set; } = "";
public string Version { get; set; } = "";
public ConnectionStatus Connection { get; set; } = new();
public SubscriptionStatus Subscriptions { get; set; } = new();
public PerformanceStatus Performance { get; set; } = new();
public HealthInfo Health { get; set; } = new();
public HealthInfo? DetailedHealth { get; set; }
}
/// <summary>
/// Connection status information
/// </summary>
public class ConnectionStatus
{
public bool IsConnected { get; set; }
public string State { get; set; } = "";
public string NodeName { get; set; } = "";
public string GalaxyName { get; set; } = "";
}
/// <summary>
/// Subscription status information
/// </summary>
public class SubscriptionStatus
{
public int TotalClients { get; set; }
public int TotalTags { get; set; }
public int ActiveSubscriptions { get; set; }
}
/// <summary>
/// Performance status information
/// </summary>
public class PerformanceStatus
{
public long TotalOperations { get; set; }
public double AverageSuccessRate { get; set; }
public Dictionary<string, OperationStatus> Operations { get; set; } = new();
}
/// <summary>
/// Individual operation status
/// </summary>
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; }
}
/// <summary>
/// Health check status information
/// </summary>
public class HealthInfo
{
public string Status { get; set; } = "";
public string Description { get; set; } = "";
public Dictionary<string, string> Data { get; set; } = new();
}
}

View File

@@ -0,0 +1,315 @@
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// HTTP web server that serves status information for the LmxProxy service
/// </summary>
public class StatusWebServer : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<StatusWebServer>();
private readonly WebServerConfiguration _configuration;
private readonly StatusReportService _statusReportService;
private CancellationTokenSource? _cancellationTokenSource;
private bool _disposed;
private HttpListener? _httpListener;
private Task? _listenerTask;
/// <summary>
/// Initializes a new instance of the StatusWebServer class
/// </summary>
/// <param name="configuration">Web server configuration</param>
/// <param name="statusReportService">Service for collecting status information</param>
public StatusWebServer(WebServerConfiguration configuration, StatusReportService statusReportService)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_statusReportService = statusReportService ?? throw new ArgumentNullException(nameof(statusReportService));
}
/// <summary>
/// Disposes the web server and releases resources
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
Stop();
_cancellationTokenSource?.Dispose();
_httpListener?.Close();
}
/// <summary>
/// Starts the HTTP web server
/// </summary>
/// <returns>True if started successfully, false otherwise</returns>
public bool Start()
{
try
{
if (!_configuration.Enabled)
{
Logger.Information("Status web server is disabled");
return true;
}
Logger.Information("Starting status web server on port {Port}", _configuration.Port);
_httpListener = new HttpListener();
// Configure the URL prefix
string prefix = _configuration.Prefix ?? $"http://+:{_configuration.Port}/";
if (!prefix.EndsWith("/"))
{
prefix += "/";
}
_httpListener.Prefixes.Add(prefix);
_httpListener.Start();
_cancellationTokenSource = new CancellationTokenSource();
_listenerTask = Task.Run(() => HandleRequestsAsync(_cancellationTokenSource.Token));
Logger.Information("Status web server started successfully on {Prefix}", prefix);
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to start status web server");
return false;
}
}
/// <summary>
/// Stops the HTTP web server
/// </summary>
/// <returns>True if stopped successfully, false otherwise</returns>
public bool Stop()
{
try
{
if (!_configuration.Enabled || _httpListener == null)
{
return true;
}
Logger.Information("Stopping status web server");
_cancellationTokenSource?.Cancel();
if (_listenerTask != null)
{
try
{
_listenerTask.Wait(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
Logger.Warning(ex, "Error waiting for listener task to complete");
}
}
_httpListener?.Stop();
_httpListener?.Close();
Logger.Information("Status web server stopped successfully");
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Error stopping status web server");
return false;
}
}
/// <summary>
/// Main request handling loop
/// </summary>
private async Task HandleRequestsAsync(CancellationToken cancellationToken)
{
Logger.Information("Status web server listener started");
while (!cancellationToken.IsCancellationRequested && _httpListener != null && _httpListener.IsListening)
{
try
{
HttpListenerContext? context = await _httpListener.GetContextAsync();
// Handle request asynchronously without waiting
_ = Task.Run(async () =>
{
try
{
await HandleRequestAsync(context);
}
catch (Exception ex)
{
Logger.Error(ex, "Error handling HTTP request from {RemoteEndPoint}",
context.Request.RemoteEndPoint);
}
}, cancellationToken);
}
catch (ObjectDisposedException)
{
// Expected when stopping the listener
break;
}
catch (HttpListenerException ex) when (ex.ErrorCode == 995) // ERROR_OPERATION_ABORTED
{
// Expected when stopping the listener
break;
}
catch (Exception ex)
{
Logger.Error(ex, "Error in request listener loop");
// Brief delay before continuing to avoid tight error loops
try
{
await Task.Delay(1000, cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
}
}
Logger.Information("Status web server listener stopped");
}
/// <summary>
/// Handles a single HTTP request
/// </summary>
private async Task HandleRequestAsync(HttpListenerContext context)
{
HttpListenerRequest? request = context.Request;
HttpListenerResponse response = context.Response;
try
{
Logger.Debug("Handling {Method} request to {Url} from {RemoteEndPoint}",
request.HttpMethod, request.Url?.AbsolutePath, request.RemoteEndPoint);
// Only allow GET requests
if (request.HttpMethod != "GET")
{
response.StatusCode = 405; // Method Not Allowed
response.StatusDescription = "Method Not Allowed";
await WriteResponseAsync(response, "Only GET requests are supported", "text/plain");
return;
}
string path = request.Url?.AbsolutePath?.ToLowerInvariant() ?? "/";
switch (path)
{
case "/":
await HandleStatusPageAsync(response);
break;
case "/api/status":
await HandleStatusApiAsync(response);
break;
case "/api/health":
await HandleHealthApiAsync(response);
break;
default:
response.StatusCode = 404; // Not Found
response.StatusDescription = "Not Found";
await WriteResponseAsync(response, "Resource not found", "text/plain");
break;
}
}
catch (Exception ex)
{
Logger.Error(ex, "Error handling HTTP request");
try
{
response.StatusCode = 500; // Internal Server Error
response.StatusDescription = "Internal Server Error";
await WriteResponseAsync(response, "Internal server error", "text/plain");
}
catch (Exception responseEx)
{
Logger.Error(responseEx, "Error writing error response");
}
}
finally
{
try
{
response.Close();
}
catch (Exception ex)
{
Logger.Warning(ex, "Error closing HTTP response");
}
}
}
/// <summary>
/// Handles the main status page (HTML)
/// </summary>
private async Task HandleStatusPageAsync(HttpListenerResponse response)
{
string statusHtml = await _statusReportService.GenerateHtmlReportAsync();
await WriteResponseAsync(response, statusHtml, "text/html; charset=utf-8");
}
/// <summary>
/// Handles the status API endpoint (JSON)
/// </summary>
private async Task HandleStatusApiAsync(HttpListenerResponse response)
{
string statusJson = await _statusReportService.GenerateJsonReportAsync();
await WriteResponseAsync(response, statusJson, "application/json; charset=utf-8");
}
/// <summary>
/// Handles the health API endpoint (simple text)
/// </summary>
private async Task HandleHealthApiAsync(HttpListenerResponse response)
{
bool isHealthy = await _statusReportService.IsHealthyAsync();
string healthText = isHealthy ? "OK" : "UNHEALTHY";
response.StatusCode = isHealthy ? 200 : 503; // Service Unavailable if unhealthy
await WriteResponseAsync(response, healthText, "text/plain");
}
/// <summary>
/// Writes a response to the HTTP context
/// </summary>
private static async Task WriteResponseAsync(HttpListenerResponse response, string content, string contentType)
{
response.ContentType = contentType;
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
response.Headers.Add("Pragma", "no-cache");
response.Headers.Add("Expires", "0");
byte[] buffer = Encoding.UTF8.GetBytes(content);
response.ContentLength64 = buffer.Length;
using (Stream? output = response.OutputStream)
{
await output.WriteAsync(buffer, 0, buffer.Length);
}
}
}
}

View File

@@ -0,0 +1,535 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Manages subscriptions for multiple gRPC clients, handling tag subscriptions, message delivery, and client
/// statistics.
/// </summary>
public class SubscriptionManager : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<SubscriptionManager>();
// Configuration for channel buffering
private readonly int _channelCapacity;
private readonly BoundedChannelFullMode _channelFullMode;
private readonly ConcurrentDictionary<string, ClientSubscription> _clientSubscriptions = new();
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.NoRecursion);
private readonly IScadaClient _scadaClient;
private readonly ConcurrentDictionary<string, TagSubscription> _tagSubscriptions = new();
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="SubscriptionManager" /> class.
/// </summary>
/// <param name="scadaClient">The SCADA client to use for subscriptions.</param>
/// <param name="configuration">The subscription configuration.</param>
/// <exception cref="ArgumentNullException">
/// Thrown if <paramref name="scadaClient" /> or <paramref name="configuration" />
/// is null.
/// </exception>
public SubscriptionManager(IScadaClient scadaClient, SubscriptionConfiguration configuration)
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
SubscriptionConfiguration configuration1 =
configuration ?? throw new ArgumentNullException(nameof(configuration));
_channelCapacity = configuration1.ChannelCapacity;
_channelFullMode = ParseChannelFullMode(configuration1.ChannelFullMode);
// Subscribe to connection state changes
_scadaClient.ConnectionStateChanged += OnConnectionStateChanged;
Logger.Information("SubscriptionManager initialized with channel capacity: {Capacity}, full mode: {Mode}",
_channelCapacity, _channelFullMode);
}
/// <summary>
/// Disposes the <see cref="SubscriptionManager" />, unsubscribing all clients and cleaning up resources.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
Logger.Information("Disposing SubscriptionManager");
// Unsubscribe from connection state changes
_scadaClient.ConnectionStateChanged -= OnConnectionStateChanged;
// Unsubscribe all clients
var clientIds = _clientSubscriptions.Keys.ToList();
foreach (string? clientId in clientIds)
{
UnsubscribeClient(clientId);
}
_clientSubscriptions.Clear();
_tagSubscriptions.Clear();
// Dispose the lock
_lock?.Dispose();
}
/// <summary>
/// Gets the number of active client subscriptions.
/// </summary>
public virtual int GetActiveSubscriptionCount() => _clientSubscriptions.Count;
/// <summary>
/// Parses the channel full mode string to <see cref="BoundedChannelFullMode" />.
/// </summary>
/// <param name="mode">The mode string.</param>
/// <returns>The parsed <see cref="BoundedChannelFullMode" /> value.</returns>
private static BoundedChannelFullMode ParseChannelFullMode(string mode)
{
return mode?.ToUpperInvariant() switch
{
"DROPOLDEST" => BoundedChannelFullMode.DropOldest,
"DROPNEWEST" => BoundedChannelFullMode.DropNewest,
"WAIT" => BoundedChannelFullMode.Wait,
_ => BoundedChannelFullMode.DropOldest // Default
};
}
/// <summary>
/// Creates a new subscription for a client to a set of tag addresses.
/// </summary>
/// <param name="clientId">The client identifier.</param>
/// <param name="addresses">The tag addresses to subscribe to.</param>
/// <param name="ct">Optional cancellation token.</param>
/// <returns>A channel for receiving tag updates.</returns>
/// <exception cref="ObjectDisposedException">Thrown if the manager is disposed.</exception>
public async Task<Channel<(string address, Vtq vtq)>> SubscribeAsync(
string clientId,
IEnumerable<string> addresses,
CancellationToken ct = default)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SubscriptionManager));
}
var addressList = addresses.ToList();
Logger.Information("Client {ClientId} subscribing to {Count} tags", clientId, addressList.Count);
// Create a bounded channel for this client with buffering
var channel = Channel.CreateBounded<(string address, Vtq vtq)>(new BoundedChannelOptions(_channelCapacity)
{
FullMode = _channelFullMode,
SingleReader = true,
SingleWriter = false,
AllowSynchronousContinuations = false
});
Logger.Debug("Created bounded channel for client {ClientId} with capacity {Capacity}", clientId,
_channelCapacity);
var clientSubscription = new ClientSubscription
{
ClientId = clientId,
Channel = channel,
Addresses = new HashSet<string>(addressList),
CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ct)
};
_clientSubscriptions[clientId] = clientSubscription;
// Subscribe to each tag
foreach (string? address in addressList)
{
await SubscribeToTagAsync(address, clientId);
}
// Handle client disconnection
clientSubscription.CancellationTokenSource.Token.Register(() =>
{
Logger.Information("Client {ClientId} disconnected, cleaning up subscriptions", clientId);
UnsubscribeClient(clientId);
});
return channel;
}
/// <summary>
/// Unsubscribes a client from all tags and cleans up resources.
/// </summary>
/// <param name="clientId">The client identifier.</param>
public void UnsubscribeClient(string clientId)
{
if (_clientSubscriptions.TryRemove(clientId, out ClientSubscription? clientSubscription))
{
Logger.Information(
"Unsubscribing client {ClientId} from {Count} tags. Stats: Delivered={Delivered}, Dropped={Dropped}",
clientId, clientSubscription.Addresses.Count,
clientSubscription.DeliveredMessageCount, clientSubscription.DroppedMessageCount);
_lock.EnterWriteLock();
try
{
foreach (string? address in clientSubscription.Addresses)
{
if (_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription))
{
tagSubscription.ClientIds.Remove(clientId);
// If no more clients are subscribed to this tag, unsubscribe from SCADA
if (tagSubscription.ClientIds.Count == 0)
{
Logger.Information(
"No more clients subscribed to {Address}, removing SCADA subscription", address);
_tagSubscriptions.TryRemove(address, out _);
// Dispose the SCADA subscription
Task.Run(async () =>
{
try
{
if (tagSubscription.ScadaSubscription != null)
{
await tagSubscription.ScadaSubscription.DisposeAsync();
Logger.Debug("Successfully disposed SCADA subscription for {Address}",
address);
}
}
catch (Exception ex)
{
Logger.Error(ex, "Error disposing SCADA subscription for {Address}", address);
}
});
}
else
{
Logger.Debug(
"Client {ClientId} removed from {Address} subscription (remaining clients: {Count})",
clientId, address, tagSubscription.ClientIds.Count);
}
}
}
}
finally
{
_lock.ExitWriteLock();
}
// Complete the channel
clientSubscription.Channel.Writer.TryComplete();
clientSubscription.CancellationTokenSource.Dispose();
}
}
/// <summary>
/// Subscribes a client to a tag address, creating a new SCADA subscription if needed.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="clientId">The client identifier.</param>
private async Task SubscribeToTagAsync(string address, string clientId)
{
bool needsSubscription;
TagSubscription? tagSubscription;
_lock.EnterWriteLock();
try
{
if (_tagSubscriptions.TryGetValue(address, out TagSubscription? existingSubscription))
{
// Tag is already subscribed, just add this client
existingSubscription.ClientIds.Add(clientId);
Logger.Debug(
"Client {ClientId} added to existing subscription for {Address} (total clients: {Count})",
clientId, address, existingSubscription.ClientIds.Count);
return;
}
// Create new tag subscription and reserve the spot
tagSubscription = new TagSubscription
{
Address = address,
ClientIds = new HashSet<string> { clientId }
};
_tagSubscriptions[address] = tagSubscription;
needsSubscription = true;
}
finally
{
_lock.ExitWriteLock();
}
if (needsSubscription && tagSubscription != null)
{
// Subscribe to SCADA outside of lock to avoid blocking
Logger.Debug("Creating new SCADA subscription for {Address}", address);
try
{
IAsyncDisposable scadaSubscription = await _scadaClient.SubscribeAsync(
new[] { address },
(addr, vtq) => OnTagValueChanged(addr, vtq),
CancellationToken.None);
_lock.EnterWriteLock();
try
{
tagSubscription.ScadaSubscription = scadaSubscription;
}
finally
{
_lock.ExitWriteLock();
}
Logger.Information("Successfully subscribed to {Address} for client {ClientId}", address, clientId);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to subscribe to {Address}", address);
// Remove the failed subscription
_lock.EnterWriteLock();
try
{
_tagSubscriptions.TryRemove(address, out _);
}
finally
{
_lock.ExitWriteLock();
}
throw;
}
}
}
/// <summary>
/// Handles tag value changes and delivers updates to all subscribed clients.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="vtq">The value, timestamp, and quality.</param>
private void OnTagValueChanged(string address, Vtq vtq)
{
Logger.Debug("Tag value changed: {Address} = {Vtq}", address, vtq);
_lock.EnterReadLock();
try
{
if (!_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription))
{
Logger.Warning("Received update for untracked tag {Address}", address);
return;
}
// Send update to all subscribed clients
// Use the existing collection directly without ToList() since we're in a read lock
foreach (string? clientId in tagSubscription.ClientIds)
{
if (_clientSubscriptions.TryGetValue(clientId, out ClientSubscription? clientSubscription))
{
try
{
if (!clientSubscription.Channel.Writer.TryWrite((address, vtq)))
{
// Channel is full - with DropOldest mode, this should rarely happen
Logger.Warning(
"Channel full for client {ClientId}, dropping message for {Address}. Consider increasing buffer size.",
clientId, address);
clientSubscription.DroppedMessageCount++;
}
else
{
clientSubscription.DeliveredMessageCount++;
}
}
catch (InvalidOperationException ex) when (ex.Message.Contains("closed"))
{
Logger.Debug("Channel closed for client {ClientId}, removing subscription", clientId);
// Schedule cleanup of disconnected client
Task.Run(() => UnsubscribeClient(clientId));
}
catch (Exception ex)
{
Logger.Error(ex, "Error sending update to client {ClientId}", clientId);
}
}
}
}
finally
{
_lock.ExitReadLock();
}
}
/// <summary>
/// Gets current subscription statistics for all clients and tags.
/// </summary>
/// <returns>A <see cref="SubscriptionStats" /> object containing statistics.</returns>
public virtual SubscriptionStats GetSubscriptionStats()
{
_lock.EnterReadLock();
try
{
var tagClientCounts = _tagSubscriptions.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ClientIds.Count);
var clientStats = _clientSubscriptions.ToDictionary(
kvp => kvp.Key,
kvp => new ClientStats
{
SubscribedTags = kvp.Value.Addresses.Count,
DeliveredMessages = kvp.Value.DeliveredMessageCount,
DroppedMessages = kvp.Value.DroppedMessageCount
});
return new SubscriptionStats
{
TotalClients = _clientSubscriptions.Count,
TotalTags = _tagSubscriptions.Count,
TagClientCounts = tagClientCounts,
ClientStats = clientStats
};
}
finally
{
_lock.ExitReadLock();
}
}
/// <summary>
/// Handles SCADA client connection state changes and notifies clients of disconnection.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The connection state change event arguments.</param>
private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
{
Logger.Information("Connection state changed from {Previous} to {Current}",
e.PreviousState, e.CurrentState);
// If we're disconnected, notify all subscribed clients with bad quality
if (e.CurrentState != ConnectionState.Connected)
{
Task.Run(async () =>
{
try
{
await NotifyAllClientsOfDisconnection();
}
catch (Exception ex)
{
Logger.Error(ex, "Error notifying clients of disconnection");
}
});
}
}
/// <summary>
/// Notifies all clients of a SCADA disconnection by sending bad quality updates.
/// </summary>
private async Task NotifyAllClientsOfDisconnection()
{
Logger.Information("Notifying all clients of disconnection");
var badQualityVtq = new Vtq(null, DateTime.UtcNow, Quality.Bad);
// Get all unique addresses being subscribed to
var allAddresses = _tagSubscriptions.Keys.ToList();
// Send bad quality update for each address to all subscribed clients
foreach (string? address in allAddresses)
{
if (_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription))
{
var clientIds = tagSubscription.ClientIds.ToList();
foreach (string? clientId in clientIds)
{
if (_clientSubscriptions.TryGetValue(clientId, out ClientSubscription? clientSubscription))
{
try
{
await clientSubscription.Channel.Writer.WriteAsync((address, badQualityVtq));
Logger.Debug("Sent bad quality notification for {Address} to client {ClientId}",
address, clientId);
}
catch (Exception ex)
{
Logger.Warning(ex, "Failed to send bad quality notification to client {ClientId}",
clientId);
}
}
}
}
}
}
/// <summary>
/// Represents a client's subscription, including channel, addresses, and statistics.
/// </summary>
private class ClientSubscription
{
/// <summary>
/// Gets or sets the client identifier.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the channel for delivering tag updates.
/// </summary>
public Channel<(string address, Vtq vtq)> Channel { get; set; } = null!;
/// <summary>
/// Gets or sets the set of addresses the client is subscribed to.
/// </summary>
public HashSet<string> Addresses { get; set; } = new();
/// <summary>
/// Gets or sets the cancellation token source for the client.
/// </summary>
public CancellationTokenSource CancellationTokenSource { get; set; } = null!;
/// <summary>
/// Gets or sets the count of delivered messages.
/// </summary>
public long DeliveredMessageCount { get; set; }
/// <summary>
/// Gets or sets the count of dropped messages.
/// </summary>
public long DroppedMessageCount { get; set; }
}
/// <summary>
/// Represents a tag subscription, including address, client IDs, and SCADA subscription handle.
/// </summary>
private class TagSubscription
{
/// <summary>
/// Gets or sets the tag address.
/// </summary>
public string Address { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the set of client IDs subscribed to this tag.
/// </summary>
public HashSet<string> ClientIds { get; set; } = new();
/// <summary>
/// Gets or sets the SCADA subscription handle.
/// </summary>
public IAsyncDisposable ScadaSubscription { get; set; } = null!;
}
}
}