using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos; using FinlyticCore.Models; using FinlyticCore.Models.Settings; using FinlyticCore.Services; 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, /// typed/generic message handling, and synchronous Request-Reply (RPC). /// Supports channel-controlled logging via . /// public abstract class ManagedMqttClient : IDisposable { protected static readonly JsonSerializerOptions DefaultJsonOptions = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private readonly ILogger _logger; private readonly ISettingsService? _settingsService; private readonly IFinlyticLogger? _finlyticLogger; private readonly IMqttClient _mqttClient; private CancellationTokenSource? _cts; // Tracks pending RPC requests waiting for a specific correlation ID reply private readonly ConcurrentDictionary> _pendingRequests = new(); /// /// Literal suffix appended to a normal RPC response topic to build its "fault" sibling topic, e.g. /// services/response/{channel}/{correlationId}/error. Publishing faults on a distinct topic (instead /// of on the regular response topic with some in-payload error marker) lets a caller recognize a fault /// deterministically from the topic string alone, before ever attempting to deserialize the body as the /// expected TResponse — which matters because a generic RPC client has no way to heuristically tell a /// legitimate TResponse payload apart from an error payload shaped like something else. /// It also makes the scheme degrade safely across a rolling deployment: an old client (pre-dating this /// suffix) that receives a new server's fault message extracts "error" as a bogus correlation ID, finds no /// matching pending request, and simply falls through — it keeps waiting and eventually times out exactly as /// it did before this feature existed, instead of crashing or misinterpreting the payload. Symmetrically, a /// new client talking to an old server that never publishes this topic at all simply times out as before. /// private const string ErrorTopicSuffix = "/error"; // Tracks registered topic handlers for direct routing private readonly ConcurrentDictionary>> _topicHandlers = new(StringComparer.OrdinalIgnoreCase); // Tracks all active topic filters for automatic re-subscription on reconnect private readonly ConcurrentDictionary _subscribedTopics = new(StringComparer.OrdinalIgnoreCase); /// /// Gets a value indicating whether the client is currently connected to the MQTT broker. /// public bool IsConnected => _mqttClient.IsConnected; protected ManagedMqttClient( ILogger logger, ISettingsService? settingsService = null, IFinlyticLogger? finlyticLogger = null) { _logger = logger; _settingsService = settingsService; _finlyticLogger = finlyticLogger; _mqttClient = new MqttClientFactory().CreateMqttClient(); _mqttClient.ApplicationMessageReceivedAsync += HandleIncomingMessageAsync; _mqttClient.DisconnectedAsync += HandleDisconnectAsync; } private async Task LogMqttInfoAsync(string message, params object[] args) { if (_finlyticLogger != null) { await _finlyticLogger.LogInfoAsync(CoreSettingKeys.MqttChannel, message, args); } else if (_settingsService != null) { if (await _settingsService.GetSettingAsync(CoreSettingKeys.MqttChannel)) { _logger.LogInformation(message, args); } } else { _logger.LogInformation(message, args); } } private async Task LogMqttDebugAsync(string message, params object[] args) { if (_finlyticLogger != null) { await _finlyticLogger.LogDebugAsync(CoreSettingKeys.MqttChannel, message, args); } else if (_settingsService != null) { if (await _settingsService.GetSettingAsync(CoreSettingKeys.MqttChannel)) { _logger.LogDebug(message, args); } } else { _logger.LogDebug(message, args); } } /// /// Establishes a connection to the MQTT broker and initializes the background auto-reconnection loop. /// 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(); await LogMqttInfoAsync("Connecting to MQTT broker at {Host}:{Port}...", config.Host, config.Port); try { await _mqttClient.ConnectAsync(options, _cts.Token); await LogMqttInfoAsync("Successfully connected to MQTT broker."); await ResubscribeAllAsync(); await OnConnectedAsync(); } catch (Exception ex) { _logger.LogError(ex, "Failed to establish initial connection to MQTT broker. Reconnection loop will handle recovery."); } } private bool _disposed; /// /// Gracefully disconnects from the broker and stops all ongoing background loops. /// public async Task DisconnectAsync() { if (_disposed) return; if (_cts != null) { try { await _cts.CancelAsync(); } catch (ObjectDisposedException) { } } if (_mqttClient.IsConnected) { try { await _mqttClient.DisconnectAsync(new MqttClientDisconnectOptions { Reason = MqttClientDisconnectOptionsReason.NormalDisconnection }); await LogMqttInfoAsync("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 without attaching a direct handler. /// public async Task SubscribeAsync(string topic, bool noLocal = false) { _subscribedTopics[topic] = noLocal; if (!IsConnected) { _logger.LogWarning("Subscription to topic '{Topic}' queued: Client is currently offline.", topic); return; } await ExecuteSubscriptionAsync(topic, noLocal); } /// /// Subscribes to a specific MQTT topic filter and maps an asynchronous raw string handler (topic, payload). /// public async Task SubscribeAsync(string topic, Func handler, bool noLocal = false) { RegisterTopicHandler(topic, handler); await SubscribeAsync(topic, noLocal); } /// /// Subscribes to a specific MQTT topic filter and maps a synchronous raw string handler (topic, payload). /// public async Task SubscribeAsync(string topic, Action handler, bool noLocal = false) { RegisterTopicHandler(topic, (t, p) => { handler(t, p); return Task.CompletedTask; }); await SubscribeAsync(topic, noLocal); } /// /// Subscribes to a specific MQTT topic filter and maps an asynchronous handler receiving the raw payload string. /// public async Task SubscribeAsync(string topic, Func handler, bool noLocal = false) { RegisterTopicHandler(topic, (_, p) => handler(p)); await SubscribeAsync(topic, noLocal); } /// /// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into , /// extracts the correlation ID, and invokes the asynchronous handler with (payload, topic, correlationId). /// public async Task SubscribeAsync(string topic, Func handler, bool noLocal = false) { RegisterTopicHandler(topic, async (t, p) => { var data = DeserializePayload(p); var correlationId = ExtractCorrelationId(t); await handler(data, t, correlationId); }); await SubscribeAsync(topic, noLocal); } /// /// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into , /// extracts the correlation ID, and invokes the synchronous handler with (payload, topic, correlationId). /// public async Task SubscribeAsync(string topic, Action handler, bool noLocal = false) { RegisterTopicHandler(topic, (t, p) => { var data = DeserializePayload(p); var correlationId = ExtractCorrelationId(t); handler(data, t, correlationId); return Task.CompletedTask; }); await SubscribeAsync(topic, noLocal); } /// /// Registers a server-side RPC handler that listens on a request topic (e.g. "services/request/assets_Get/#"), /// executes the delegate, and publishes the returned to "services/response/{channel}/{correlationId}". /// If the request payload cannot be deserialized into , or if /// throws, no response is silently dropped: a typed /// fault is published instead (see ), so a caller using /// observes a specific fault instead of only ever /// hitting its request timeout. /// public async Task SubscribeRpcAsync(string requestTopic, Func> handler, bool noLocal = false) { RegisterTopicHandler(requestTopic, async (t, p) => { var correlationId = ExtractCorrelationId(t); if (string.IsNullOrEmpty(correlationId)) return; var segments = t.Split('/', StringSplitOptions.RemoveEmptyEntries); var channel = segments.Length >= 3 ? segments[2] : "unknown"; var responseTopic = MqttTopics.ResponseTopic(channel, correlationId); TRequest? req; try { req = DeserializePayload(p); } catch (Exception ex) { await PublishRpcFaultAsync(responseTopic, RpcFaultCode.InvalidArgument, "The request payload could not be parsed.", ex); return; } TResponse result; try { result = await handler(req, correlationId); } catch (Exception ex) { await PublishRpcFaultAsync(responseTopic, ClassifyFault(ex), SafeFaultMessage(ex), ex); return; } await PublishAsync(responseTopic, result); }); await SubscribeAsync(requestTopic, noLocal); } /// /// Maps an exception thrown by an RPC handler onto the small, coarse set so the /// caller-side can reconstruct an equivalent standard /// .NET exception type across the MQTT boundary (see for the mapping rationale). /// /// The exception thrown by the RPC handler. /// The fault classification to report to the caller. private static RpcFaultCode ClassifyFault(Exception ex) => ex switch { ArgumentException => RpcFaultCode.InvalidArgument, KeyNotFoundException => RpcFaultCode.NotFound, UnauthorizedAccessException => RpcFaultCode.Unauthorized, InvalidOperationException => RpcFaultCode.Conflict, _ => RpcFaultCode.Internal }; /// /// Produces the message text that is safe to place on the (currently unauthenticated) MQTT broker for a /// given RPC handler exception. Exceptions that already carry a deliberately-authored, business-facing /// message (the four types recognizes) are passed through as-is; anything else /// is replaced with a generic message, since it may be an unexpected infrastructure failure whose message /// could contain internal details. The original exception (including its stack trace) is always logged /// locally by regardless of which branch is taken. /// /// The exception thrown by the RPC handler. /// A short, safe message describing the fault to an external caller. private static string SafeFaultMessage(Exception ex) => ex switch { ArgumentException or KeyNotFoundException or UnauthorizedAccessException or InvalidOperationException => ex.Message, _ => "An internal error occurred while processing the request." }; /// /// Logs an RPC handler fault locally (with full exception detail) and publishes a corresponding /// to the fault sibling of (see /// ), so the caller of /// observes a typed fault instead of silently timing out. If the fault publish itself fails (e.g. the /// broker connection dropped between receiving the request and reporting the fault), that secondary failure /// is logged but not rethrown, since the caller's request timeout is still a safe fallback in that case. /// /// The normal ("success") response topic for the failed request. /// The machine-readable fault classification to report. /// The safe, non-sensitive message to report. /// The original exception, logged locally in full but never placed on the wire. private async Task PublishRpcFaultAsync(string responseTopic, RpcFaultCode code, string message, Exception ex) { _logger.LogError(ex, "RPC handler faulted for response topic '{ResponseTopic}'. Reporting fault {FaultCode} to the caller.", responseTopic, code); try { await PublishAsync(responseTopic + ErrorTopicSuffix, new RpcErrorResponse(code, message)); } catch (Exception publishEx) { _logger.LogError(publishEx, "Failed to publish RPC fault response to '{ResponseTopic}'; the caller will fall back to its request timeout.", responseTopic + ErrorTopicSuffix); } } /// /// Registers a server-side RPC handler without correlation ID parameter in the delegate. /// public async Task SubscribeRpcAsync(string requestTopic, Func> handler, bool noLocal = false) { await SubscribeRpcAsync(requestTopic, (req, _) => handler(req), noLocal); } /// /// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into , /// and invokes the asynchronous handler with (payload, topic). /// public async Task SubscribeAsync(string topic, Func handler, bool noLocal = false) { RegisterTopicHandler(topic, async (t, p) => { var data = DeserializePayload(p); await handler(data, t); }); await SubscribeAsync(topic, noLocal); } /// /// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into , /// and invokes the asynchronous handler with the payload. /// public async Task SubscribeAsync(string topic, Func handler, bool noLocal = false) { RegisterTopicHandler(topic, async (_, p) => { var data = DeserializePayload(p); await handler(data); }); await SubscribeAsync(topic, noLocal); } /// /// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into , /// and invokes the synchronous handler with (payload, topic). /// public async Task SubscribeAsync(string topic, Action handler, bool noLocal = false) { RegisterTopicHandler(topic, (t, p) => { var data = DeserializePayload(p); handler(data, t); return Task.CompletedTask; }); await SubscribeAsync(topic, noLocal); } /// /// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into , /// and invokes the synchronous handler with the payload. /// public async Task SubscribeAsync(string topic, Action handler, bool noLocal = false) { RegisterTopicHandler(topic, (_, p) => { var data = DeserializePayload(p); handler(data); return Task.CompletedTask; }); await SubscribeAsync(topic, noLocal); } private void RegisterTopicHandler(string topic, Func handler) { _topicHandlers.AddOrUpdate( topic, _ => new List> { handler }, (_, list) => { lock (list) { list.Add(handler); } return list; }); } private async Task ExecuteSubscriptionAsync(string topic, bool noLocal) { 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); await LogMqttDebugAsync("Successfully subscribed to topic: {Topic} (NoLocal: {NoLocal})", topic, noLocal); } private async Task ResubscribeAllAsync() { foreach (var kvp in _subscribedTopics) { try { await ExecuteSubscriptionAsync(kvp.Key, kvp.Value); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to re-subscribe to topic '{Topic}' after reconnect.", kvp.Key); } } } /// /// 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. /// Uses standard System.Text.Json with fallback to Source Generators. /// public Task PublishAsync(string topic, T data, bool retain = false) { if (!IsConnected) throw new InvalidOperationException("Cannot publish message: MQTT client is offline."); byte[] jsonBytes; if (data is string str) { jsonBytes = Encoding.UTF8.GetBytes(str); } else if (data is byte[] b) { jsonBytes = b; } else { try { jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data, DefaultJsonOptions); } catch { 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); } } } var message = new MqttApplicationMessageBuilder() .WithTopic(topic) .WithPayload(jsonBytes) .WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce) .WithRetainFlag(retain) .Build(); return _mqttClient.PublishAsync(message, CancellationToken.None); } /// /// Sends a parameterless request to an RPC channel and asynchronously blocks until a matching response arrives. /// See for the exact timeout/fault-propagation contract. /// public Task SendRpcRequestAsync( string channel, TimeSpan? timeout = null) where TResponse : class { return SendRpcRequestAsync(channel, string.Empty, timeout); } /// /// Sends a generic request payload to an RPC channel and asynchronously waits for a matching response. /// See for the exact timeout/fault-propagation contract. /// public Task RequestAsync( string channel, TRequest requestData, TimeSpan? timeout = null) where TResponse : class where TRequest : class { var cleanChannel = channel.StartsWith(MqttTopics.RequestPrefix) ? channel.Substring(MqttTopics.RequestPrefix.Length).TrimEnd('/') : channel; return SendRpcRequestAsync(cleanChannel, requestData, timeout); } /// /// 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}. /// If the serving handler faulted, the server publishes an on the sibling /// error topic () instead of the normal response; this method then throws a /// reconstructed exception (an , , /// , , or, for anything that does /// not map onto one of those, an ) instead of returning. This lets a caller /// distinguish a specific server-side fault from an unreachable/silent server, which still surfaces as a /// -driven null return exactly as before this fault channel existed. /// /// The remote handler reported . /// The remote handler reported , or the client is offline. /// The remote handler reported . /// The remote handler reported . /// The remote handler reported , or its fault payload could not be parsed. 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."); string correlationId = Guid.NewGuid().ToString("N"); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingRequests.TryAdd(correlationId, tcs); string requestTopic = MqttTopics.RequestTopic(channel, correlationId); await PublishAsync(requestTopic, requestData); await LogMqttInfoAsync("RPC request published to '{Topic}' [CorrelationId: {Id}]", requestTopic, correlationId); try { var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(25); var rawJsonResult = await tcs.Task.WaitAsync(effectiveTimeout); if (typeof(TResponse) == typeof(string)) { return rawJsonResult as TResponse; } return DeserializePayload(rawJsonResult); } catch (TimeoutException) { _logger.LogWarning("RPC request timed out on channel '{Channel}' [CorrelationId: {Id}]", channel, correlationId); return null; } finally { _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); await LogMqttDebugAsync("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(MqttTopics.ResponsePrefix)) { // A fault sibling topic ends in ErrorTopicSuffix (see SubscribeRpcAsync/PublishRpcFaultAsync); // strip it before extracting the correlation ID so both topic shapes resolve the same pending // request. An old client build (pre-dating this suffix) would instead extract "error" itself // as a bogus correlation ID, find no matching pending request below, and fall through to time // out exactly as it did before this fault channel existed - see ErrorTopicSuffix remarks. bool isFault = topic.EndsWith(ErrorTopicSuffix, StringComparison.Ordinal); var correlationTopic = isFault ? topic[..^ErrorTopicSuffix.Length] : topic; var lastSlashIndex = correlationTopic.LastIndexOf('/'); if (lastSlashIndex != -1) { string correlationId = correlationTopic[(lastSlashIndex + 1)..]; if (_pendingRequests.TryRemove(correlationId, out var tcs)) { if (isFault) { tcs.SetException(BuildFaultException(payload ?? string.Empty)); } else { tcs.SetResult(payload ?? string.Empty); } return; // Sinks the message, avoiding triggering OnMessageReceivedAsync for active RPC handles } } } // Match registered topic handlers foreach (var kvp in _topicHandlers) { if (TopicMatches(kvp.Key, topic)) { List> handlersCopy; lock (kvp.Value) { handlersCopy = new List>(kvp.Value); } for (int i = 0; i < handlersCopy.Count; i++) { try { await handlersCopy[i](topic, payload ?? string.Empty); } catch (Exception ex) { OnError(ex); } } } } // Regular Pub/Sub message propagation (for overridden OnMessageReceivedAsync) await OnMessageReceivedAsync(topic, payload ?? string.Empty); } catch (Exception ex) { OnError(ex); } }); return Task.CompletedTask; } private async Task HandleDisconnectAsync(MqttClientDisconnectedEventArgs e) { 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 { await LogMqttInfoAsync("Reconnect attempt {Attempt} in {Delay}s...", attempt, delaySeconds); await Task.Delay(TimeSpan.FromSeconds(delaySeconds), _cts.Token); await _mqttClient.ReconnectAsync(_cts.Token); if (_mqttClient.IsConnected) { await LogMqttInfoAsync("MQTT client reconnected successfully after {Attempt} attempt(s).", attempt); await ResubscribeAllAsync(); await OnConnectedAsync(); return; } } catch (OperationCanceledException) { return; } catch (Exception ex) { _logger.LogWarning(ex, "Reconnect attempt {Attempt} to the MQTT broker failed.", attempt); } } } /// /// Deserializes a JSON string payload into using standard System.Text.Json with fallback. /// public static T? DeserializePayload(string payload) { if (string.IsNullOrWhiteSpace(payload)) return default; if (typeof(T) == typeof(string)) return (T)(object)payload; try { return JsonSerializer.Deserialize(payload, DefaultJsonOptions); } catch { var typeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(T)); if (typeInfo != null) { return (T?)JsonSerializer.Deserialize(payload, typeInfo); } throw; } } /// /// Reconstructs the exception a caller should observe for a fault reported on an RPC error topic (see /// / ). Faults whose /// maps onto a familiar .NET exception type are thrown as that type /// (see ), so pre-existing catch blocks written against the underlying /// service-layer exception types (e.g. in FinlyticBackend controllers) start working across the MQTT /// boundary without any changes on the caller's side. Anything else, including a fault payload that fails /// to parse, becomes an . /// /// The raw JSON payload received on the fault topic. /// The exception to throw to the RPC caller. private Exception BuildFaultException(string payload) { RpcErrorResponse? fault; try { fault = DeserializePayload(payload); } catch (Exception ex) { _logger.LogError(ex, "Failed to parse RPC fault payload; propagating a generic RpcFaultException instead."); return new RpcFaultException(RpcFaultCode.Internal, "The remote service reported an error that could not be parsed."); } if (fault == null) { return new RpcFaultException(RpcFaultCode.Internal, "The remote service reported an empty error response."); } return fault.Code switch { RpcFaultCode.InvalidArgument => new ArgumentException(fault.Message), RpcFaultCode.Conflict => new InvalidOperationException(fault.Message), RpcFaultCode.NotFound => new KeyNotFoundException(fault.Message), RpcFaultCode.Unauthorized => new UnauthorizedAccessException(fault.Message), _ => new RpcFaultException(fault.Code, fault.Message) }; } /// /// Checks whether an MQTT topic matches a topic filter with wildcards ('+' and '#'). /// public static bool TopicMatches(string filter, string topic) { if (string.Equals(filter, topic, StringComparison.OrdinalIgnoreCase)) return true; if (filter == "#") return true; var filterSegments = filter.Split('/'); var topicSegments = topic.Split('/'); for (int i = 0; i < filterSegments.Length; i++) { var f = filterSegments[i]; if (f == "#") { return true; } if (i >= topicSegments.Length) { return false; } var t = topicSegments[i]; if (f != "+" && !string.Equals(f, t, StringComparison.OrdinalIgnoreCase)) { return false; } } return filterSegments.Length == topicSegments.Length; } /// /// Extracts the Correlation ID from the end of an RPC request or response topic (e.g. services/request/abc/123 -> 123). /// public static string ExtractCorrelationId(string topic) { if (string.IsNullOrWhiteSpace(topic)) return string.Empty; var lastSlash = topic.LastIndexOf('/'); return lastSlash >= 0 && lastSlash < topic.Length - 1 ? topic[(lastSlash + 1)..] : string.Empty; } /// /// Fired automatically whenever a connection or reconnection is successfully established. /// protected abstract Task OnConnectedAsync(); /// /// Fired whenever a new message lands on a registered subscription channel. /// protected virtual Task OnMessageReceivedAsync(string topic, string payload) => Task.CompletedTask; /// /// 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() { if (_disposed) return; _disposed = true; try { DisconnectAsync().GetAwaiter().GetResult(); } catch { } _cts?.Dispose(); _mqttClient.Dispose(); GC.SuppressFinalize(this); } } /// /// Thrown client-side by when a remote /// RPC handler reported a fault () whose has no /// equivalent standard .NET exception type — i.e. , or a fault payload /// that could not be parsed at all. Faults that DO map onto an existing exception type /// ( to , /// to , /// to , /// to ) are deliberately /// thrown as that familiar type instead of this one: several existing callers (e.g. /// FinlyticBackend/Controllers/UserTradesController.cs) already have catch (InvalidOperationException) /// / catch (ArgumentException) blocks written for the exception types the underlying service-layer /// methods throw locally, and reusing those types here reactivates that existing code instead of requiring /// every caller to learn and catch a brand new exception type. /// public sealed class RpcFaultException : Exception { /// Gets the machine-readable fault classification reported by the remote RPC handler. public RpcFaultCode Code { get; } /// /// Initializes a new instance carrying the remote fault's classification and its safe, non-sensitive message. /// /// The machine-readable fault classification reported by the remote RPC handler. /// The safe, non-sensitive message reported by the remote handler. public RpcFaultException(RpcFaultCode code, string message) : base(message) { Code = code; } }