using System; using System.Collections.Concurrent; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Models; using Microsoft.Extensions.Logging; using MQTTnet; namespace FinlyticCore.Util; /// /// An abstract, resilient MQTT client wrapper designed for microservice architectures. /// Handles automatic reconnection, structured JSON publishing, thread-safe subscription management, and synchronous Request-Reply (RPC). /// public abstract class ManagedMqttClient : IDisposable { private readonly ILogger _logger; private readonly IMqttClient _mqttClient; private CancellationTokenSource? _cts; // Tracks pending RPC requests waiting for a specific correlation ID reply private readonly ConcurrentDictionary> _pendingRequests = new(); /// /// Gets a value indicating whether the client is currently connected to the MQTT broker. /// public bool IsConnected => _mqttClient.IsConnected; protected ManagedMqttClient(ILogger logger) { _logger = logger; _mqttClient = new MqttClientFactory().CreateMqttClient(); _mqttClient.ApplicationMessageReceivedAsync += HandleIncomingMessageAsync; _mqttClient.DisconnectedAsync += HandleDisconnectAsync; } /// /// Establishes a connection to the MQTT broker and initializes the background auto-reconnection loop. /// /// The network and credential configuration options for the broker. public async Task ConnectAsync(MqttConfiguration config) { if (IsConnected) throw new InvalidOperationException("MQTT client is already connected."); _cts = new CancellationTokenSource(); var optionsBuilder = new MqttClientOptionsBuilder() .WithTcpServer(config.Host, config.Port) .WithClientId(config.ClientId) .WithCleanSession(); if (!string.IsNullOrWhiteSpace(config.Username)) { optionsBuilder.WithCredentials(config.Username, config.Password); } var options = optionsBuilder.Build(); _logger.LogInformation("Connecting to MQTT broker at {Host}:{Port}...", config.Host, config.Port); try { await _mqttClient.ConnectAsync(options, _cts.Token); _logger.LogInformation("Successfully connected to MQTT broker."); await OnConnectedAsync(); } catch (Exception ex) { _logger.LogError(ex, "Failed to establish initial connection to MQTT broker. Reconnection loop will handle recovery."); } } /// /// Gracefully disconnects from the broker and stops all ongoing background loops. /// public async Task DisconnectAsync() { if (_cts != null) { await _cts.CancelAsync(); } if (_mqttClient.IsConnected) { try { await _mqttClient.DisconnectAsync(new MqttClientDisconnectOptions { Reason = MqttClientDisconnectOptionsReason.NormalDisconnection }); _logger.LogInformation("MQTT connection gracefully closed."); } catch (Exception ex) { _logger.LogWarning(ex, "An error occurred while disconnecting from the MQTT broker."); } } } /// /// Subscribes to a specific MQTT topic filter. /// /// The topic pattern or wildcard to subscribe to. /// If set to true, the broker will not forward messages published by this client back to itself. protected async Task SubscribeAsync(string topic, bool noLocal = false) { if (!IsConnected) { _logger.LogWarning("Subscription to topic '{Topic}' delayed: Client is currently offline.", topic); return; } var filterBuilder = new MqttTopicFilterBuilder().WithTopic(topic); if (noLocal) { filterBuilder.WithNoLocal(); } var subscribeOptions = new MqttClientFactory().CreateSubscribeOptionsBuilder() .WithTopicFilter(filterBuilder.Build()) .Build(); await _mqttClient.SubscribeAsync(subscribeOptions, CancellationToken.None); _logger.LogDebug("Successfully subscribed to topic: {Topic} (NoLocal: {NoLocal})", topic, noLocal); } /// /// Publishes a raw string message payload to the specified topic. /// public async Task PublishAsync(string topic, string payload, bool retain = false) { if (!IsConnected) throw new InvalidOperationException("Cannot publish message: MQTT client is offline."); var message = new MqttApplicationMessageBuilder() .WithTopic(topic) .WithPayload(payload) .WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce) .WithRetainFlag(retain) .Build(); await _mqttClient.PublishAsync(message, CancellationToken.None); } /// /// Serializes a generic object into a structured JSON string and publishes it to the specified topic. /// Utilizes .NET 8 JSON Source Generators for zero-reflection overhead, with reflection fallback for unregistered types. /// public Task PublishAsync(string topic, T data, bool retain = false) { byte[] jsonBytes; var typeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(T)) ?? (data != null ? FinlyticJsonSerializerContext.Default.GetTypeInfo(data.GetType()) : null); if (typeInfo != null) { jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data, typeInfo); } else { jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data); } if (!IsConnected) throw new InvalidOperationException("Cannot publish message: MQTT client is offline."); var message = new MqttApplicationMessageBuilder() .WithTopic(topic) .WithPayload(jsonBytes) .WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce) .WithRetainFlag(retain) .Build(); return _mqttClient.PublishAsync(message, CancellationToken.None); } /// /// Sends a generic request payload to an RPC channel and asynchronously blocks until a matching response arrives. /// Uses the topic conventions: services/request/{channel}/{correlationId} and services/response/{channel}/{correlationId}. /// /// The expected strongly-typed object type of the reply. /// The type of the payload being transmitted. /// The target sub-channel or service name (e.g., "sentix", "assets"). /// The object that will be serialized to JSON and sent. /// Optional. Maximum time to wait before returning null. Defaults to 10 seconds. public async Task SendRpcRequestAsync( string channel, TRequest requestData, TimeSpan? timeout = null) where TResponse : class where TRequest : class { if (!IsConnected) throw new InvalidOperationException("Cannot execute RPC request: MQTT client is offline."); // 1. Generate a unique Correlation ID for this specific transaction string correlationId = Guid.NewGuid().ToString("N"); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingRequests.TryAdd(correlationId, tcs); string requestTopic = $"services/request/{channel}/{correlationId}"; // 2. Serialize and dispatch via the existing JSON helper await PublishAsync(requestTopic, requestData); _logger.LogInformation("RPC request published to '{Topic}' [CorrelationId: {Id}]", requestTopic, correlationId); try { // 3. Block asynchronously until the response loop resolves the token var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(25); var rawJsonResult = await tcs.Task.WaitAsync(effectiveTimeout); if (typeof(TResponse) == typeof(string)) { return rawJsonResult as TResponse; } var respTypeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(TResponse)); if (respTypeInfo != null) { return JsonSerializer.Deserialize(rawJsonResult, respTypeInfo) as TResponse; } return JsonSerializer.Deserialize(rawJsonResult); } catch (TimeoutException) { _logger.LogWarning("RPC request timed out on channel '{Channel}' [CorrelationId: {Id}]", channel, correlationId); return null; } finally { // Always clean up the dictionary to prevent memory leaks _pendingRequests.TryRemove(correlationId, out _); } } private Task HandleIncomingMessageAsync(MqttApplicationMessageReceivedEventArgs e) { _ = Task.Run(async () => { try { var topic = e.ApplicationMessage.Topic; var payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload); _logger.LogInformation("MQTT message received on topic '{Topic}', length={Length}", topic, payload?.Length ?? 0); // Intercept message if it belongs to the RPC response convention if (topic.StartsWith("services/response/")) { var lastSlashIndex = topic.LastIndexOf('/'); if (lastSlashIndex != -1) { string correlationId = topic[(lastSlashIndex + 1)..]; if (_pendingRequests.TryRemove(correlationId, out var tcs)) { tcs.SetResult(payload ?? string.Empty); return; // Sinks the message, avoiding triggering OnMessageReceivedAsync for active RPC handles } } } // Regular Pub/Sub message propagation await OnMessageReceivedAsync(topic, payload ?? string.Empty); } catch (Exception ex) { OnError(ex); } }); return Task.CompletedTask; } private async Task HandleDisconnectAsync(MqttClientDisconnectedEventArgs e) { // Prevent trigger during deliberate connection shutdowns if (_cts == null || _cts.IsCancellationRequested) return; _logger.LogWarning("Lost connection to MQTT broker (Reason: {Reason}). Initiating auto-reconnect loop...", e.Reason); int attempt = 0; while (_cts != null && !_cts.IsCancellationRequested) { attempt++; var delaySeconds = Math.Min(5 * Math.Pow(2, attempt - 1), 60); // 5s, 10s, 20s, 40s, 60s max try { _logger.LogInformation("Reconnect attempt {Attempt} in {Delay}s...", attempt, delaySeconds); await Task.Delay(TimeSpan.FromSeconds(delaySeconds), _cts.Token); await _mqttClient.ReconnectAsync(_cts.Token); if (_mqttClient.IsConnected) { _logger.LogInformation("MQTT client reconnected successfully after {Attempt} attempt(s).", attempt); await OnConnectedAsync(); return; } } catch (OperationCanceledException) { return; /* Expected on application shutdown */ } catch (Exception ex) { _logger.LogWarning(ex, "Reconnect attempt {Attempt} to the MQTT broker failed.", attempt); } } } /// /// Fired automatically whenever a connection or reconnection is successfully established. /// Ideal place to trigger operations. /// protected abstract Task OnConnectedAsync(); /// /// Fired whenever a new message lands on a registered subscription channel. /// /// The specific topic where the message was broadcasted. /// The deserialized UTF-8 payload string. protected abstract Task OnMessageReceivedAsync(string topic, string payload); /// /// Virtual fallback method to catch and handle processing level exceptions inside the incoming pipeline. /// protected virtual void OnError(Exception ex) { _logger.LogError(ex, "An unhandled exception occurred within the ManagedMqttClient messaging pipeline."); } public void Dispose() { DisconnectAsync().GetAwaiter().GetResult(); _cts?.Dispose(); _mqttClient.Dispose(); GC.SuppressFinalize(this); } }