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.
///
public Task PublishAsync(string topic, T data, bool retain = false)
{
var jsonOptions = new JsonSerializerOptions
{
ReferenceHandler = ReferenceHandler.IgnoreCycles
};
var json = JsonSerializer.Serialize(data, jsonOptions);
return PublishAsync(topic, json, retain);
}
///
/// 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.LogDebug("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(10);
var rawJsonResult = await tcs.Task.WaitAsync(effectiveTimeout);
if (typeof(TResponse) == typeof(string))
{
return rawJsonResult 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 async Task HandleIncomingMessageAsync(MqttApplicationMessageReceivedEventArgs e)
{
try
{
var topic = e.ApplicationMessage.Topic;
var payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload);
// 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);
return; // Sinks the message, avoiding triggering OnMessageReceivedAsync for active RPC handles
}
}
}
// Regular Pub/Sub message propagation
await OnMessageReceivedAsync(topic, payload);
}
catch (Exception ex)
{
OnError(ex);
}
}
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 in 5 seconds...", e.Reason);
try
{
await Task.Delay(TimeSpan.FromSeconds(5), _cts.Token);
await _mqttClient.ReconnectAsync(_cts.Token);
if (_mqttClient.IsConnected)
{
_logger.LogInformation("MQTT client reconnected successfully.");
await OnConnectedAsync();
}
}
catch (OperationCanceledException) { /* Expected swallow on application shutdown */ }
catch (Exception ex)
{
_logger.LogError(ex, "Reconnection attempt to the MQTT broker failed.");
}
}
///
/// 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);
}
}