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
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MeterVault.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class BindUnboundMqttSourcesToSoleBroker : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// MQTT routing now honours meter_source.endpoint_id (SDD §6.1): a source is served only
// by the broker it is bound to. Previously an unbound source was subscribed on every
// broker and matched on topic alone, so unbound sources that work today would silently
// go quiet after this deploy.
//
// Backfill them onto the single broker only when exactly one exists — then the old
// "any broker" behaviour and the new "its broker" behaviour are the same thing, so the
// rewrite is provably lossless. With zero brokers there is nothing to bind to; with two
// or more the old behaviour was already ambiguous and a guess could route a meter's
// data to the wrong broker, so those are left for the operator to resolve in the UI.
//
// Enums persist as their C# names (HasConversion<string>), hence 'Mqtt'/'MqttBroker'.
// HomeAssistant sources are deliberately excluded: the HA workers have always required
// endpoint_id, so an unbound HA source is already inert and binding it here would
// activate ingestion the operator never had running.
migrationBuilder.Sql("""
UPDATE meter_source AS s
SET endpoint_id = sole.id
FROM (SELECT id FROM ingestion_endpoint WHERE type = 'MqttBroker') AS sole
WHERE s.endpoint_id IS NULL
AND s.source_type IN ('Mqtt', 'Tasmota')
AND (SELECT count(*) FROM ingestion_endpoint WHERE type = 'MqttBroker') = 1;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Intentionally empty. The rows this bound are indistinguishable from ones the operator
// bound by hand, so clearing endpoint_id on the way down would discard real
// configuration. Leaving the binding in place is harmless under the old routing, which
// ignored endpoint_id entirely.
}
}
}