Ingestion: route MQTT messages only to sources bound to the delivering broker
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:
2026-07-18 11:21:36 +02:00
parent 9bd0d60cc8
commit c0bbaba99f
5 changed files with 1053 additions and 13 deletions
@@ -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;