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.
217 lines
9.0 KiB
C#
217 lines
9.0 KiB
C#
using MeterVault.Core.Analysis;
|
|
using MeterVault.Core.Analysis.Rollups;
|
|
using MeterVault.Infrastructure.Persistence;
|
|
using MeterVault.Infrastructure.Persistence.Analysis;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
|
|
|
namespace MeterVault.Infrastructure.Normalization;
|
|
|
|
/// <summary>
|
|
/// Writes one meter's analysis tables (D-12) — day and month rollups, coverage runs and rollup state — by diff
|
|
/// against what is stored: a row that is still right is not touched, a changed one is updated in place, a new
|
|
/// one added and a vanished one removed. Stages the changes; the caller's <c>SaveChanges</c> writes them in its
|
|
/// transaction, together with the meter's consumption.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A recompute runs on every live reading, and almost all of a meter's history comes out of it unchanged. A
|
|
/// diff keeps that to the rows the new reading actually moved — usually the current day and month and the
|
|
/// last coverage run — instead of rewriting years of rows each time.
|
|
/// </para>
|
|
/// <para>
|
|
/// The change tracker, not only the database, is the truth here: one context may recompute a meter several
|
|
/// times before it saves (an import touching a meter twice, a worker ingesting two readings in one scope).
|
|
/// Rows staged by an earlier pass are still tracked — added, modified or deleted — and the diff builds on
|
|
/// them rather than tripping over their keys.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class AnalysisDataWriter(MeterVaultDbContext db)
|
|
{
|
|
/// <summary>Every analysis table is keyed by meter first, under this property.</summary>
|
|
private const string MeterIdProperty = nameof(MeterRollupState.MeterId);
|
|
|
|
private readonly MeterVaultDbContext _db = db;
|
|
|
|
/// <summary>
|
|
/// Stages the meter's rollups and coverage, and its state row. <see cref="MeterRollupState.BuiltAt"/> moves to
|
|
/// <paramref name="now"/> only when something about the meter's analysis data changed.
|
|
/// </summary>
|
|
/// <returns>True when any row was added, changed or removed.</returns>
|
|
public async Task<bool> WriteAsync(
|
|
int meterId,
|
|
MeterRollups rollups,
|
|
IReadOnlyList<CoverageRun> coverage,
|
|
MeterRollupState state,
|
|
DateTimeOffset now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(rollups);
|
|
ArgumentNullException.ThrowIfNull(coverage);
|
|
ArgumentNullException.ThrowIfNull(state);
|
|
|
|
// Listing tracked entries detects changes across the whole context by default — every consumption row
|
|
// the recompute just staged, several times over. Nothing here depends on it: the diff compares values
|
|
// itself, and SaveChanges detects the edits it makes.
|
|
var autoDetect = _db.ChangeTracker.AutoDetectChangesEnabled;
|
|
_db.ChangeTracker.AutoDetectChangesEnabled = false;
|
|
try
|
|
{
|
|
return await WriteCoreAsync(meterId, rollups, coverage, state, now, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
_db.ChangeTracker.AutoDetectChangesEnabled = autoDetect;
|
|
}
|
|
}
|
|
|
|
private async Task<bool> WriteCoreAsync(
|
|
int meterId,
|
|
MeterRollups rollups,
|
|
IReadOnlyList<CoverageRun> coverage,
|
|
MeterRollupState state,
|
|
DateTimeOffset now,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var changed = false;
|
|
|
|
var days = await CurrentAsync(_db.ConsumptionRollups, meterId, cancellationToken).ConfigureAwait(false);
|
|
changed |= Sync(
|
|
days, rollups.Days, d => (d.Day, d.Kind), b => (b.Start, b.Kind),
|
|
(row, bucket) => row.Assign(bucket), bucket => ConsumptionRollup.From(meterId, bucket));
|
|
|
|
var months = await CurrentAsync(_db.ConsumptionRollupMonths, meterId, cancellationToken).ConfigureAwait(false);
|
|
changed |= Sync(
|
|
months, rollups.Months, m => (m.Month, m.Kind), b => (b.Start, b.Kind),
|
|
(row, bucket) => row.Assign(bucket), bucket => ConsumptionRollupMonth.From(meterId, bucket));
|
|
|
|
var runs = await CurrentAsync(_db.MeterCoverage, meterId, cancellationToken).ConfigureAwait(false);
|
|
changed |= Sync(
|
|
runs, coverage, r => r.SpanFrom, r => r.From.ToUniversalTime(),
|
|
(row, run) => row.Assign(run), run => MeterCoverageRun.From(meterId, run));
|
|
|
|
var states = await CurrentAsync(_db.MeterRollupStates, meterId, cancellationToken).ConfigureAwait(false);
|
|
var entry = states.SingleOrDefault();
|
|
if (entry is null)
|
|
{
|
|
_db.MeterRollupStates.Add(new MeterRollupState
|
|
{
|
|
MeterId = meterId,
|
|
Revision = state.Revision,
|
|
Zone = state.Zone,
|
|
NormalizedUnit = state.NormalizedUnit,
|
|
Kind = state.Kind,
|
|
LastReadingAt = state.LastReadingAt,
|
|
BuiltAt = now.ToUniversalTime(),
|
|
});
|
|
return true;
|
|
}
|
|
|
|
var stored = entry.Entity;
|
|
var resurrected = entry.State == EntityState.Deleted;
|
|
if (resurrected)
|
|
{
|
|
entry.State = EntityState.Modified;
|
|
}
|
|
|
|
var stateChanged = resurrected
|
|
|| stored.Revision != state.Revision
|
|
|| !string.Equals(stored.Zone, state.Zone, StringComparison.Ordinal)
|
|
|| !string.Equals(stored.NormalizedUnit, state.NormalizedUnit, StringComparison.Ordinal)
|
|
|| stored.Kind != state.Kind
|
|
|| !Nullable.Equals(stored.LastReadingAt, state.LastReadingAt);
|
|
if (changed || stateChanged)
|
|
{
|
|
stored.Revision = state.Revision;
|
|
stored.Zone = state.Zone;
|
|
stored.NormalizedUnit = state.NormalizedUnit;
|
|
stored.Kind = state.Kind;
|
|
stored.LastReadingAt = state.LastReadingAt;
|
|
stored.BuiltAt = now.ToUniversalTime();
|
|
}
|
|
|
|
return changed || stateChanged;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The meter's rows as they stand for this context: the stored rows (tracked, so identity resolution hands
|
|
/// back anything an earlier pass already holds) plus rows an earlier pass added and has not saved, minus the
|
|
/// ones it removed, which keep their entry (state Deleted) so they can be brought back.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A tracked row the database no longer has — removed behind the tracker's back, as <c>ExecuteDelete</c> or a
|
|
/// cascade does — is detached, as the consumption rows are: updating or deleting it would fail the save.
|
|
/// </remarks>
|
|
private async Task<List<EntityEntry<T>>> CurrentAsync<T>(DbSet<T> set, int meterId, CancellationToken cancellationToken)
|
|
where T : class
|
|
{
|
|
var stored = await set.Where(e => EF.Property<int>(e, MeterIdProperty) == meterId)
|
|
.ToListAsync(cancellationToken).ConfigureAwait(false);
|
|
var inDatabase = new HashSet<T>(stored, ReferenceEqualityComparer.Instance);
|
|
|
|
var entries = _db.ChangeTracker.Entries<T>()
|
|
.Where(e => e.State != EntityState.Detached && (int)e.Property(MeterIdProperty).CurrentValue! == meterId)
|
|
.ToList();
|
|
foreach (var stale in entries.Where(e => e.State != EntityState.Added && !inDatabase.Contains(e.Entity)))
|
|
{
|
|
stale.State = EntityState.Detached;
|
|
}
|
|
|
|
return [.. entries.Where(e => e.State != EntityState.Detached)];
|
|
}
|
|
|
|
/// <summary>Reconciles the tracked rows of one table with the desired values; true when anything changed.</summary>
|
|
private bool Sync<TEntity, TDesired, TKey>(
|
|
List<EntityEntry<TEntity>> current,
|
|
IEnumerable<TDesired> desired,
|
|
Func<TEntity, TKey> entityKey,
|
|
Func<TDesired, TKey> desiredKey,
|
|
Func<TEntity, TDesired, bool> assign,
|
|
Func<TDesired, TEntity> create)
|
|
where TEntity : class
|
|
where TKey : notnull
|
|
{
|
|
var changed = false;
|
|
var byKey = current.ToDictionary(e => entityKey(e.Entity));
|
|
var wanted = new HashSet<TKey>();
|
|
|
|
foreach (var item in desired)
|
|
{
|
|
var key = desiredKey(item);
|
|
wanted.Add(key);
|
|
if (!byKey.TryGetValue(key, out var entry))
|
|
{
|
|
_db.Add(create(item));
|
|
changed = true;
|
|
continue;
|
|
}
|
|
|
|
if (entry.State == EntityState.Deleted)
|
|
{
|
|
// Removed by an earlier pass that has not been saved: the row is wanted again, so it stays
|
|
// and is updated with whatever it holds now.
|
|
assign(entry.Entity, item);
|
|
entry.State = EntityState.Modified;
|
|
changed = true;
|
|
continue;
|
|
}
|
|
|
|
changed |= assign(entry.Entity, item);
|
|
}
|
|
|
|
foreach (var (key, entry) in byKey)
|
|
{
|
|
if (wanted.Contains(key) || entry.State == EntityState.Deleted)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// An added row is simply dropped from tracking; a stored one is deleted.
|
|
_db.Remove(entry.Entity);
|
|
changed = true;
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
}
|