Polish/audit: fix bugs found by 3 subsystem audits
ci / build-test (push) Successful in 2m45s

Correctness/data:
- Fix demo cost double-count: reference importer no longer imports the Kosten Strom/Wasser
  columns for categories that are metered (only Heizung), so Wasser rollup is 70€ not 140€.
- Spurious-decrease guard: only a reset/swap in the window (prevReading, thisReading] explains
  a decrease — an old historical reset no longer permanently disables the guard.
- Gate swap auto-detection on MappingProfile.DetectCumulativeSwaps (flag was ignored).
- Prorate basePrice by bucket length (day/month/year); guard virtual expressions against NaN/Inf.

Concurrency/infra:
- Blazor: register a DbContextFactory; CostService/DashboardService and the read pages now use
  short-lived per-operation contexts (no shared circuit DbContext); guard Trends re-entrancy.
- /events: wrap event insert + consumption recompute in one transaction (atomic); 404 (not 500)
  on unknown meter.
- MQTT worker: subscribe to newly-added topics on each tick; move client cleanup into finally.
- Migrations: CREATE MATERIALIZED VIEW IF NOT EXISTS + if_not_exists on CAgg/compression/
  hypertable calls (re-run-safe after a mid-migration crash).
- HA worker: prune stale poll-schedule entries; export: null dangling ImportBatchIds on restore.

API/security:
- API fail-closed by default: with no keys and AllowAnonymousApi off, /api/v1 returns 401
  (protects /export and /import). New MeterVault:AllowAnonymousApi opt-in.
- Cap /readings batch at 5000; report ignored (unknown-meter) count; enums as strings in JSON.

+4 regression tests (guard window, API closed, /events 404, no demo double-count). 98 tests
green; Docker deploy re-verified healthy with the API fail-closed.

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
2026-07-13 12:56:51 +02:00
parent e223278771
commit a6edec2b12
28 changed files with 326 additions and 109 deletions
@@ -67,6 +67,13 @@ public sealed class HomeAssistantWorker(
.Where(s => s.IsEnabled && s.SourceType == SourceType.HomeAssistant && s.EndpointId != null)
.ToListAsync(cancellationToken).ConfigureAwait(false);
// Drop schedule entries for sources that are gone/disabled so the dictionary doesn't grow.
var liveIds = sources.Select(s => s.Id).ToHashSet();
foreach (var staleId in _nextPoll.Keys.Where(id => !liveIds.Contains(id)).ToList())
{
_nextPoll.TryRemove(staleId, out _);
}
var now = DateTimeOffset.UtcNow;
foreach (var source in sources)
{
@@ -112,7 +112,7 @@ public sealed class IngestionService(MeterVaultDbContext db)
var previous = await _db.Readings
.Where(r => r.MeterId == meterId && r.Time < time)
.OrderByDescending(r => r.Time)
.Select(r => (double?)r.Value)
.Select(r => new { r.Value, r.Time })
.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
if (previous is null || value >= previous.Value)
@@ -120,11 +120,12 @@ public sealed class IngestionService(MeterVaultDbContext db)
return false;
}
// A reset/swap event between the previous reading and this one explains the decrease.
// Only a reset/swap in the window (previousReading, thisReading] explains the decrease
// an old historical reset must not permanently disable the guard.
var explained = await _db.MeterEvents.AnyAsync(
e => e.MeterId == meterId
&& (e.EventType == MeterEventType.CounterReset || e.EventType == MeterEventType.MeterSwap)
&& e.Time <= time,
&& e.Time > previous.Time && e.Time <= time,
cancellationToken).ConfigureAwait(false);
return !explained;
@@ -24,42 +24,52 @@ public sealed class MqttIngestionWorker(
private readonly ILogger<MqttIngestionWorker> _logger = logger;
private readonly MqttClientFactory _factory = new();
private readonly ConcurrentDictionary<int, IMqttClient> _clients = new();
private readonly ConcurrentDictionary<int, HashSet<string>> _subscribed = new();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(ReconnectInterval);
do
try
{
try
do
{
await EnsureConnectionsAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MQTT ingestion tick failed; will retry");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
foreach (var client in _clients.Values)
{
try
{
if (client.IsConnected)
try
{
await client.DisconnectAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
await EnsureConnectionsAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MQTT ingestion tick failed; will retry");
}
}
catch (Exception ex)
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
}
catch (OperationCanceledException)
{
// Normal shutdown while idle waiting for the next tick.
}
finally
{
foreach (var client in _clients.Values)
{
_logger.LogDebug(ex, "Error disconnecting MQTT client during shutdown");
}
try
{
if (client.IsConnected)
{
await client.DisconnectAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error disconnecting MQTT client during shutdown");
}
client.Dispose();
client.Dispose();
}
}
}
@@ -75,13 +85,32 @@ public sealed class MqttIngestionWorker(
foreach (var endpoint in endpoints)
{
var client = _clients.GetOrAdd(endpoint.Id, _ => CreateClient());
if (client.IsConnected)
{
continue;
}
var topics = await ResolveTopicsAsync(db, endpoint, cancellationToken).ConfigureAwait(false);
await ConnectAndSubscribeAsync(endpoint, client, topics, cancellationToken).ConfigureAwait(false);
if (!client.IsConnected)
{
_subscribed[endpoint.Id] = [];
await ConnectAndSubscribeAsync(endpoint, client, topics, cancellationToken).ConfigureAwait(false);
}
else
{
// Already connected — subscribe to any topics added since the last tick.
await SubscribeNewTopicsAsync(endpoint.Id, client, topics, cancellationToken).ConfigureAwait(false);
}
}
}
private async Task SubscribeNewTopicsAsync(
int endpointId, IMqttClient client, IReadOnlyList<string> topics, CancellationToken cancellationToken)
{
var known = _subscribed.GetOrAdd(endpointId, _ => []);
foreach (var topic in topics)
{
if (known.Add(topic))
{
await client.SubscribeAsync(topic, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation("MQTT subscribed to new topic {Topic} on endpoint {Endpoint}", topic, endpointId);
}
}
}
@@ -114,9 +143,11 @@ public sealed class MqttIngestionWorker(
try
{
await client.ConnectAsync(builder.Build(), cancellationToken).ConfigureAwait(false);
var known = _subscribed.GetOrAdd(endpoint.Id, _ => []);
foreach (var topic in topics)
{
await client.SubscribeAsync(topic, cancellationToken: cancellationToken).ConfigureAwait(false);
known.Add(topic);
}
_logger.LogInformation("MQTT connected to {Host}:{Port} ({TopicCount} topics)",