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.
195 lines
7.6 KiB
C#
195 lines
7.6 KiB
C#
using MeterVault.Core.Analysis;
|
|
using MeterVault.Core.Analysis.Rollups;
|
|
using MeterVault.Core.Domain;
|
|
|
|
namespace MeterVault.Infrastructure.Persistence.Analysis;
|
|
|
|
// The analysis tables (D-12). All four are derived: RecomputeMeterAsync writes them from the same engine
|
|
// output it stores as consumption, in the caller's transaction, by diff. They are plain tables — not
|
|
// hypertables — each keyed by meter first, with an FK to meter that cascades, so deleting a meter (or
|
|
// wiping all of them) never trips over them.
|
|
|
|
/// <summary>The columns a day and a month rollup share (<see cref="RollupBucket"/> without its start).</summary>
|
|
public abstract class ConsumptionRollupBase
|
|
{
|
|
public int MeterId { get; set; }
|
|
|
|
/// <summary>Consumption or generation, as the consumption rows are stored.</summary>
|
|
public ConsumptionKind Kind { get; set; }
|
|
|
|
/// <summary>The sum of the bucket's rows; signed.</summary>
|
|
public double Amount { get; set; }
|
|
|
|
public double Measured { get; set; }
|
|
|
|
public double Manual { get; set; }
|
|
|
|
public double Imported { get; set; }
|
|
|
|
/// <summary>Divided, coalesced, interpolated or otherwise inferred amounts.</summary>
|
|
public double Estimated { get; set; }
|
|
|
|
/// <summary>How many consumption rows the bucket sums.</summary>
|
|
public int Rows { get; set; }
|
|
|
|
/// <summary>Opening balance (1) and divided (2) markers.</summary>
|
|
public RollupFlags Flags { get; set; }
|
|
|
|
/// <summary>The latest source-interval end among the bucket's rows (A-05), UTC.</summary>
|
|
public DateTimeOffset MaxIntervalEnd { get; set; }
|
|
|
|
/// <summary>The bucket this row stores, with <paramref name="start"/> as its day or month.</summary>
|
|
protected RollupBucket ToBucket(DateOnly start) =>
|
|
new(start, Kind, Amount, Measured, Manual, Imported, Estimated, Rows, Flags, MaxIntervalEnd);
|
|
|
|
/// <summary>Copies every column but the key from <paramref name="bucket"/>; true when anything changed.</summary>
|
|
internal bool Assign(RollupBucket bucket)
|
|
{
|
|
var end = bucket.MaxIntervalEnd.ToUniversalTime();
|
|
if (Amount.Equals(bucket.Amount) && Measured.Equals(bucket.Measured) && Manual.Equals(bucket.Manual)
|
|
&& Imported.Equals(bucket.Imported) && Estimated.Equals(bucket.Estimated) && Rows == bucket.Rows
|
|
&& Flags == bucket.Flags && MaxIntervalEnd == end && MaxIntervalEnd.Offset == TimeSpan.Zero)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Amount = bucket.Amount;
|
|
Measured = bucket.Measured;
|
|
Manual = bucket.Manual;
|
|
Imported = bucket.Imported;
|
|
Estimated = bucket.Estimated;
|
|
Rows = bucket.Rows;
|
|
Flags = bucket.Flags;
|
|
MaxIntervalEnd = end;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>One local day of one kind for one meter: table <c>consumption_rollup</c>, key (meter_id, day, kind).</summary>
|
|
public sealed class ConsumptionRollup : ConsumptionRollupBase
|
|
{
|
|
/// <summary>The local calendar day in the instance zone.</summary>
|
|
public DateOnly Day { get; set; }
|
|
|
|
public RollupBucket ToBucket() => ToBucket(Day);
|
|
|
|
public static ConsumptionRollup From(int meterId, RollupBucket bucket)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(bucket);
|
|
|
|
var row = new ConsumptionRollup { MeterId = meterId, Day = bucket.Start, Kind = bucket.Kind };
|
|
row.Assign(bucket);
|
|
return row;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One local month of one kind for one meter: table <c>consumption_rollup_month</c>, key (meter_id, month, kind).
|
|
/// Month and year reads use it.
|
|
/// </summary>
|
|
public sealed class ConsumptionRollupMonth : ConsumptionRollupBase
|
|
{
|
|
/// <summary>The first day of the local month.</summary>
|
|
public DateOnly Month { get; set; }
|
|
|
|
public RollupBucket ToBucket() => ToBucket(Month);
|
|
|
|
public static ConsumptionRollupMonth From(int meterId, RollupBucket bucket)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(bucket);
|
|
|
|
var row = new ConsumptionRollupMonth { MeterId = meterId, Month = bucket.Start, Kind = bucket.Kind };
|
|
row.Assign(bucket);
|
|
return row;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One stored coverage run of a meter (D-13): table <c>meter_coverage</c>, key (meter_id, span_from). Stored
|
|
/// uncapped (A-04); a reader caps it at its own now with <see cref="Core.Analysis.Coverage.CoverageRuns.CapAt"/>.
|
|
/// </summary>
|
|
public sealed class MeterCoverageRun
|
|
{
|
|
public int MeterId { get; set; }
|
|
|
|
public DateTimeOffset SpanFrom { get; set; }
|
|
|
|
public DateTimeOffset SpanTo { get; set; }
|
|
|
|
public ResolutionClass ResolutionClass { get; set; }
|
|
|
|
public bool DividedAtMonths { get; set; }
|
|
|
|
/// <summary>Not <see cref="CoverageGapReason.None"/> for a known hole rather than covered time.</summary>
|
|
public CoverageGapReason GapReason { get; set; }
|
|
|
|
/// <summary>Where the run's final interval starts (A-04).</summary>
|
|
public DateTimeOffset? LastIntervalStart { get; set; }
|
|
|
|
public CoverageRun ToRun() => new(SpanFrom, SpanTo, ResolutionClass, DividedAtMonths, GapReason, LastIntervalStart);
|
|
|
|
public static MeterCoverageRun From(int meterId, CoverageRun run)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(run);
|
|
|
|
var row = new MeterCoverageRun { MeterId = meterId, SpanFrom = run.From.ToUniversalTime() };
|
|
row.Assign(run);
|
|
return row;
|
|
}
|
|
|
|
/// <summary>Copies every column but the key from <paramref name="run"/>; true when anything changed.</summary>
|
|
internal bool Assign(CoverageRun run)
|
|
{
|
|
var to = run.To.ToUniversalTime();
|
|
var last = run.LastIntervalStart?.ToUniversalTime();
|
|
if (SpanTo == to && SpanTo.Offset == TimeSpan.Zero && ResolutionClass == run.Resolution
|
|
&& DividedAtMonths == run.DividedAtMonths && GapReason == run.Gap && Nullable.Equals(LastIntervalStart, last))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
SpanTo = to;
|
|
ResolutionClass = run.Resolution;
|
|
DividedAtMonths = run.DividedAtMonths;
|
|
GapReason = run.Gap;
|
|
LastIntervalStart = last;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// What a meter's stored analysis data was built with (D-16): table <c>meter_rollup_state</c>, one row per meter.
|
|
/// A reader whose revision or zone differs — or that finds no row — reports the meter's analysis as being
|
|
/// prepared, never as "no data".
|
|
/// </summary>
|
|
public sealed class MeterRollupState
|
|
{
|
|
public int MeterId { get; set; }
|
|
|
|
/// <summary>The normalization revision the rows were built with (<c>NormalizationUpgrade.CurrentRevision</c>).</summary>
|
|
public int Revision { get; set; }
|
|
|
|
/// <summary>The timezone id local days and months were cut in.</summary>
|
|
public string Zone { get; set; } = string.Empty;
|
|
|
|
/// <summary>The unit every amount of the meter is in (D-20); canonical spelling.</summary>
|
|
public string NormalizedUnit { get; set; } = string.Empty;
|
|
|
|
/// <summary>What the meter's amounts measure (D-20).</summary>
|
|
public QuantityKind Kind { get; set; }
|
|
|
|
/// <summary>
|
|
/// The stamp of the meter's latest raw reading when its analysis data was built, or null when it has none
|
|
/// (A-40). Every write path recomputes the meter inline, so this is the freshness mark of D-18 without a
|
|
/// query over <c>reading</c> — whose chunks a reader would otherwise have to plan across on every request.
|
|
/// </summary>
|
|
public DateTimeOffset? LastReadingAt { get; set; }
|
|
|
|
/// <summary>
|
|
/// When the meter's analysis data last changed: a rollup or coverage row was added, changed or removed, or
|
|
/// one of the columns above changed. A recompute that reproduces the stored data leaves it alone, so it
|
|
/// doubles as a change marker for anything cached from these tables.
|
|
/// </summary>
|
|
public DateTimeOffset BuiltAt { get; set; }
|
|
}
|