Files
MeterVault/tests/Integration.Tests/Analysis/WindowSumPlanTests.cs
T
Florian Schmidt 5a6f34a467
ci / build-test (push) Successful in 2m34s
Analysis: bound the freshness query, let window sums skip chunks, share the Overview's catalog
Three things the 1,000-meter x 10-year measurement found, each proved by
EXPLAIN or a statement count before and after. No tally moves.

Freshness had two jobs in one unbounded query. The mark -- when a meter
last delivered -- is now stored on meter_rollup_state and maintained by
every recompute, with a one-pass backfill in the migration, so an
import-only meter keeps its years-old last activity without reading a
single raw row. The rhythm that decides stale versus live is sampled
inside a 90-day window and only for meters that actually have a live
source; a source silent for longer than that is re-read unbounded, so it
is still called stale by its own rhythm rather than by a default. The
portfolio query went from 13.8 ms planning plus 36.1 ms execution across
all 123 reading chunks to 0.58 plus 0.44 ms across four.

Window sums took their time bounds only from the unnest join, so the
planner could not exclude chunks: a 1,960-window case scanned 1.39 M rows
in parallel and spilled a 45 MB sort. Repeating the overall min and max as
constants makes it five chunks and nested-loop index scans, 121.5 ms to
8.7 ms.

The Overview read the catalog three times, once for the quantities and
once for each of its two bills. One context and one catalog snapshot now
feed all three: 31 statements per load to 23.

The final timings on an idle machine are in docs/ANALYSIS_REPORT.md: the
brief's target request (100 meters, ten years, monthly) is 286 ms against
two seconds, and a startup rebuild of 1,000 meters is 279 s.
2026-09-20 11:16:48 +02:00

185 lines
7.9 KiB
C#

using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Options;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace MeterVault.Integration.Tests.Analysis;
/// <summary>
/// The window-sum statement of the reader (D-15, A-40). Its windows arrive through an <c>unnest</c> join, so their
/// bounds are columns: without the overall <c>[min from, max to)</c> repeated as constants, PostgreSQL has nothing to
/// exclude chunks by and plans — and, with enough windows, scans — the whole <c>consumption</c> hypertable. The bounds
/// are pure arithmetic over the window set, so they may never change a tally; this pins both halves of that.
/// </summary>
[Collection("Timescale")]
public sealed class WindowSumPlanTests(TimescaleFixture fx) : IAsyncLifetime
{
private const string BerlinId = "Europe/Berlin";
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById(BerlinId);
/// <summary>The reader's statement (<c>AnalysisQueries.WindowSumsAsync</c>).</summary>
private const string Bounded = """
SELECT w.idx, count(*)::int, sum(c.amount)
FROM unnest(@ids, @froms, @tos) WITH ORDINALITY AS w(meter_id, from_time, to_time, idx)
JOIN consumption c ON c.meter_id = w.meter_id AND c.time >= w.from_time AND c.time < w.to_time
WHERE c.time >= @min_from AND c.time < @max_to
GROUP BY w.idx
""";
/// <summary>The same without the overall bounds, as it was sent before A-40.</summary>
private const string Unbounded = """
SELECT w.idx, count(*)::int, sum(c.amount)
FROM unnest(@ids, @froms, @tos) WITH ORDINALITY AS w(meter_id, from_time, to_time, idx)
JOIN consumption c ON c.meter_id = w.meter_id AND c.time >= w.from_time AND c.time < w.to_time
GROUP BY w.idx
""";
private int _meter;
private short _type;
public async Task InitializeAsync()
{
await using var db = fx.CreateContext();
var type = new EnergyType
{
Key = $"windows-{Guid.NewGuid():N}",
DisplayName = "Window sums",
BaseUnit = "kWh",
DefaultMode = MeterMode.CumulativeCounter,
};
db.EnergyTypes.Add(type);
await db.SaveChangesAsync();
_type = type.Id;
var meter = new Meter
{
Name = $"windows-{Guid.NewGuid():N}",
EnergyTypeId = _type,
Mode = MeterMode.CumulativeCounter,
Unit = "kWh",
InstalledAt = new DateOnly(2020, 1, 1),
};
db.Meters.Add(meter);
await db.SaveChangesAsync();
_meter = meter.Id;
// Three years of daily readings: `consumption` is chunked by 90 days, so the meter's own rows alone spread
// over a dozen chunks — enough for plan-time exclusion to be visible.
var register = 0d;
for (var day = new DateOnly(2020, 1, 2); day <= new DateOnly(2022, 12, 31); day = day.AddDays(1))
{
register += 1.5;
db.Readings.Add(new Reading
{
MeterId = _meter,
Time = GapAttribution.LocalMidnight(day, Berlin).AddHours(6),
Value = register,
Quality = ReadingQuality.Measured,
});
}
await db.SaveChangesAsync();
await Normalization(db).RecomputeMeterAsync(_meter, null);
await db.SaveChangesAsync();
}
public async Task DisposeAsync()
{
await using var db = fx.CreateContext();
await db.Consumption.Where(c => c.MeterId == _meter).ExecuteDeleteAsync();
await db.Readings.Where(r => r.MeterId == _meter).ExecuteDeleteAsync();
await db.Meters.Where(m => m.Id == _meter).ExecuteDeleteAsync();
await db.EnergyTypes.Where(t => t.Id == _type).ExecuteDeleteAsync();
}
[Fact]
public async Task The_overall_bounds_exclude_chunks_and_change_no_tally()
{
await using var connection = new NpgsqlConnection(fx.ConnectionString);
await connection.OpenAsync();
// Two windows, both inside the last three months of the meter's history.
var from = GapAttribution.LocalMidnight(new DateOnly(2022, 12, 20), Berlin);
var ids = new[] { _meter, _meter };
var froms = new[] { from, from.AddDays(3) };
var tos = new[] { from.AddDays(1), from.AddDays(4) };
var bounded = await TalliesAsync(connection, Bounded, ids, froms, tos, bounds: true);
var unbounded = await TalliesAsync(connection, Unbounded, ids, froms, tos, bounds: false);
Assert.Equal(2, bounded.Count);
Assert.Equal(unbounded, bounded);
var boundedChunks = await ChunksAsync(connection, Bounded, ids, froms, tos, bounds: true);
var unboundedChunks = await ChunksAsync(connection, Unbounded, ids, froms, tos, bounds: false);
// The old statement has to keep every chunk of the table in its plan; the bounded one keeps the few the
// windows can fall into. A table with a single chunk would make this vacuous, so the shape is asserted too.
Assert.True(unboundedChunks > 4, $"expected a chunked consumption table, saw {unboundedChunks} chunks in the plan");
Assert.True(
boundedChunks < unboundedChunks,
$"the bounded statement planned {boundedChunks} chunks, the unbounded one {unboundedChunks}");
Assert.True(boundedChunks <= 2, $"two windows three days apart should reach at most two chunks, not {boundedChunks}");
}
private static async Task<List<(long Index, int Rows, double Sum)>> TalliesAsync(
NpgsqlConnection connection, string sql, int[] ids, DateTimeOffset[] froms, DateTimeOffset[] tos, bool bounds)
{
await using var command = Prepare(connection, sql, ids, froms, tos, bounds);
var rows = new List<(long, int, double)>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
rows.Add((reader.GetInt64(0), reader.GetInt32(1), reader.GetDouble(2)));
}
rows.Sort();
return rows;
}
/// <summary>How many hypertable chunks the plan of <paramref name="sql"/> mentions.</summary>
private static async Task<int> ChunksAsync(
NpgsqlConnection connection, string sql, int[] ids, DateTimeOffset[] froms, DateTimeOffset[] tos, bool bounds)
{
await using var command = Prepare(connection, "EXPLAIN " + sql, ids, froms, tos, bounds);
var chunks = new HashSet<string>(StringComparer.Ordinal);
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
foreach (var word in reader.GetString(0).Split([' ', '(', ')', ','], StringSplitOptions.RemoveEmptyEntries))
{
if (word.StartsWith("_hyper_", StringComparison.Ordinal))
{
chunks.Add(word);
}
}
}
return chunks.Count;
}
private static NpgsqlCommand Prepare(
NpgsqlConnection connection, string sql, int[] ids, DateTimeOffset[] froms, DateTimeOffset[] tos, bool bounds)
{
var command = new NpgsqlCommand(sql, connection);
command.Parameters.AddWithValue("ids", ids);
command.Parameters.AddWithValue("froms", froms);
command.Parameters.AddWithValue("tos", tos);
if (bounds)
{
command.Parameters.AddWithValue("min_from", froms.Min());
command.Parameters.AddWithValue("max_to", tos.Max());
}
return command;
}
private static NormalizationService Normalization(MeterVaultDbContext db) =>
new(db, NormalizationEngine.CreateDefault(),
Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId }),
TimeProvider.System);
}