ci / build-test (push) Successful in 2m34s
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.
185 lines
7.9 KiB
C#
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);
|
|
}
|