Ingestion: route MQTT messages only to sources bound to the delivering broker
ci / build-test (push) Successful in 1m8s
ci / build-test (push) Successful in 1m8s
MqttMessageRouter matched purely on topic with no endpoint predicate, and RouteAsync was not even passed an endpoint id. Topic filters routinely overlap between brokers — every Tasmota install publishes tele/+/SENSOR — so with two brokers a message on A was ingested by a source bound to B. HA enforced the binding on both workers; MQTT enforced it only at subscribe time. Pass the endpoint id through: MQTTnet's event args carry the topic but not the delivering connection, so CreateClient captures the id in the handler closure. ResolveTopicsAsync drops its `|| EndpointId == null` clause to match, since an unbound source is no longer routed and subscribing its topic everywhere would only invite traffic nothing consumes. That last part would silently kill unbound sources that work today, so a data migration binds them to the single broker when exactly one exists — the case where old and new behaviour coincide. Two or more brokers is left alone: the old behaviour was already ambiguous and a guess could route a meter's data to the wrong broker. HA sources are excluded; they have always required an endpoint, so binding them would activate ingestion never previously running. Claude-Session: https://claude.ai/code/session_01V6joyergfvVLFEizH1hJLd
This commit is contained in:
@@ -84,7 +84,7 @@ public sealed class MqttIngestionWorker(
|
||||
|
||||
foreach (var endpoint in endpoints)
|
||||
{
|
||||
var client = _clients.GetOrAdd(endpoint.Id, _ => CreateClient());
|
||||
var client = _clients.GetOrAdd(endpoint.Id, id => CreateClient(id));
|
||||
var topics = await ResolveTopicsAsync(db, endpoint, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!client.IsConnected)
|
||||
@@ -114,10 +114,13 @@ public sealed class MqttIngestionWorker(
|
||||
}
|
||||
}
|
||||
|
||||
private IMqttClient CreateClient()
|
||||
// One client per endpoint, with the endpoint id captured in the handler: MQTTnet's event args
|
||||
// carry the topic but not which connection delivered it, and the router needs that to keep
|
||||
// sources bound to one broker from ingesting another's traffic.
|
||||
private IMqttClient CreateClient(int endpointId)
|
||||
{
|
||||
var client = _factory.CreateMqttClient();
|
||||
client.ApplicationMessageReceivedAsync += OnMessageAsync;
|
||||
client.ApplicationMessageReceivedAsync += args => OnMessageAsync(endpointId, args);
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -163,10 +166,12 @@ public sealed class MqttIngestionWorker(
|
||||
private static async Task<IReadOnlyList<string>> ResolveTopicsAsync(
|
||||
MeterVaultDbContext db, IngestionEndpoint endpoint, CancellationToken cancellationToken)
|
||||
{
|
||||
// Bound sources only, matching MqttMessageRouter: an unbound source is not routed, so
|
||||
// subscribing its topic on every broker would only invite traffic nothing consumes.
|
||||
var sourceConfigs = await db.MeterSources
|
||||
.Where(s => s.IsEnabled
|
||||
&& (s.SourceType == SourceType.Mqtt || s.SourceType == SourceType.Tasmota)
|
||||
&& (s.EndpointId == endpoint.Id || s.EndpointId == null))
|
||||
&& s.EndpointId == endpoint.Id)
|
||||
.Select(s => s.Config)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -188,7 +193,7 @@ public sealed class MqttIngestionWorker(
|
||||
return [.. topics];
|
||||
}
|
||||
|
||||
private async Task OnMessageAsync(MqttApplicationMessageReceivedEventArgs args)
|
||||
private async Task OnMessageAsync(int endpointId, MqttApplicationMessageReceivedEventArgs args)
|
||||
{
|
||||
var topic = args.ApplicationMessage.Topic;
|
||||
var payload = args.ApplicationMessage.ConvertPayloadToString() ?? string.Empty;
|
||||
@@ -197,7 +202,7 @@ public sealed class MqttIngestionWorker(
|
||||
{
|
||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||
var router = scope.ServiceProvider.GetRequiredService<MqttMessageRouter>();
|
||||
await router.RouteAsync(topic, payload).ConfigureAwait(false);
|
||||
await router.RouteAsync(endpointId, topic, payload).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -6,10 +6,16 @@ using Microsoft.Extensions.Logging;
|
||||
namespace MeterVault.Infrastructure.Ingestion;
|
||||
|
||||
/// <summary>
|
||||
/// Routes an incoming MQTT message to every enabled MQTT/Tasmota source whose topic filter covers
|
||||
/// it, extracts the value (and payload timestamp), and ingests it (SDD §6.1). Decoupled from the
|
||||
/// broker client so it can be exercised directly against the database in tests.
|
||||
/// Routes an incoming MQTT message to every enabled MQTT/Tasmota source that is bound to the
|
||||
/// delivering broker <em>and</em> whose topic filter covers it, extracts the value (and payload
|
||||
/// timestamp), and ingests it (SDD §6.1). Decoupled from the broker client so it can be exercised
|
||||
/// directly against the database in tests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The endpoint predicate is load-bearing, not defensive: topic filters routinely overlap between
|
||||
/// brokers (every Tasmota install publishes <c>tele/+/SENSOR</c>), so matching on topic alone would
|
||||
/// let a message from one broker be ingested by a source bound to another.
|
||||
/// </remarks>
|
||||
public sealed class MqttMessageRouter(
|
||||
MeterVaultDbContext db, IngestionService ingestion, ILogger<MqttMessageRouter> logger)
|
||||
{
|
||||
@@ -17,10 +23,13 @@ public sealed class MqttMessageRouter(
|
||||
private readonly IngestionService _ingestion = ingestion;
|
||||
private readonly ILogger<MqttMessageRouter> _logger = logger;
|
||||
|
||||
public async Task<int> RouteAsync(string topic, string payload, CancellationToken cancellationToken = default)
|
||||
public async Task<int> RouteAsync(
|
||||
int endpointId, string topic, string payload, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sources = await _db.MeterSources
|
||||
.Where(s => s.IsEnabled && (s.SourceType == SourceType.Mqtt || s.SourceType == SourceType.Tasmota))
|
||||
.Where(s => s.IsEnabled
|
||||
&& (s.SourceType == SourceType.Mqtt || s.SourceType == SourceType.Tasmota)
|
||||
&& s.EndpointId == endpointId)
|
||||
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var routed = 0;
|
||||
|
||||
Reference in New Issue
Block a user