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; } }