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:
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user