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;
///
/// 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 SaveChanges writes them in its
/// transaction, together with the meter's consumption.
///
///
///
/// 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.
///
///
/// 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.
///
///
internal sealed class AnalysisDataWriter(MeterVaultDbContext db)
{
/// Every analysis table is keyed by meter first, under this property.
private const string MeterIdProperty = nameof(MeterRollupState.MeterId);
private readonly MeterVaultDbContext _db = db;
///
/// Stages the meter's rollups and coverage, and its state row. moves to
/// only when something about the meter's analysis data changed.
///
/// True when any row was added, changed or removed.
public async Task WriteAsync(
int meterId,
MeterRollups rollups,
IReadOnlyList 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 WriteCoreAsync(
int meterId,
MeterRollups rollups,
IReadOnlyList 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,
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;
if (changed || stateChanged)
{
stored.Revision = state.Revision;
stored.Zone = state.Zone;
stored.NormalizedUnit = state.NormalizedUnit;
stored.Kind = state.Kind;
stored.BuiltAt = now.ToUniversalTime();
}
return changed || stateChanged;
}
///
/// 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.
///
///
/// A tracked row the database no longer has — removed behind the tracker's back, as ExecuteDelete or a
/// cascade does — is detached, as the consumption rows are: updating or deleting it would fail the save.
///
private async Task>> CurrentAsync(DbSet set, int meterId, CancellationToken cancellationToken)
where T : class
{
var stored = await set.Where(e => EF.Property(e, MeterIdProperty) == meterId)
.ToListAsync(cancellationToken).ConfigureAwait(false);
var inDatabase = new HashSet(stored, ReferenceEqualityComparer.Instance);
var entries = _db.ChangeTracker.Entries()
.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)];
}
/// Reconciles the tracked rows of one table with the desired values; true when anything changed.
private bool Sync(
List> current,
IEnumerable desired,
Func entityKey,
Func desiredKey,
Func assign,
Func create)
where TEntity : class
where TKey : notnull
{
var changed = false;
var byKey = current.ToDictionary(e => entityKey(e.Entity));
var wanted = new HashSet();
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;
}
}