From 29c12869435b69487df190d6d04c2f97ccaa54e8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 09:42:31 -0400 Subject: [PATCH] =?UTF-8?q?fix(kpi):=20untracked=20projected=20fold=20fetc?= =?UTF-8?q?h=20=E2=80=94=20fold=20reads=20no=20longer=20flood=20the=20chan?= =?UTF-8?q?ge=20tracker=20(plan=20R2-04=20T1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repositories/KpiHistoryRepository.cs | 10 ++++++++++ .../Repositories/KpiHistoryRepositoryTests.cs | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs index d772b543..2f6fd26a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs @@ -101,8 +101,14 @@ public sealed class KpiHistoryRepository : IKpiHistoryRepository // fetch it and group IN MEMORY. This deliberately avoids provider-specific // DATEADD/DATEPART hour-truncation translation (SQLite in tests, SQL Server in prod), // keeping the hour-bucket arithmetic identical across providers and trivially correct. + // Projected, untracked fetch: the fold only reads these six fields and never + // mutates a KpiSample. A tracked ToListAsync registered ~5k-90k read-only + // entities in the change tracker per healthy 3h fold — all re-scanned by + // DetectChanges on the final SaveChanges (arch-review 04 round 2, R2). A + // projection is inherently untracked and materializes no entity at all. var samples = await _context.KpiSamples .Where(s => s.CapturedAtUtc >= from && s.CapturedAtUtc < to) + .Select(s => new FoldSample(s.Source, s.Metric, s.Scope, s.ScopeKey, s.CapturedAtUtc, s.Value)) .ToListAsync(cancellationToken); if (samples.Count == 0) { @@ -212,4 +218,8 @@ public sealed class KpiHistoryRepository : IKpiHistoryRepository /// In-memory grouping key: one KPI series (four-tuple) within one UTC hour. private readonly record struct SeriesHourKey( string Source, string Metric, string Scope, string? ScopeKey, DateTime HourStartUtc); + + /// Narrow, untracked projection of one KpiSample row for the fold (R2). + private readonly record struct FoldSample( + string Source, string Metric, string Scope, string? ScopeKey, DateTime CapturedAtUtc, double Value); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs index 68822e4e..4aa216f8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs @@ -386,6 +386,26 @@ public class KpiHistoryRepositoryTests Assert.Equal(new[] { 3d, 4d }, remaining.Select(p => p.Value).ToArray()); } + [Fact] + public async Task FoldHourlyRollups_DoesNotTrack_KpiSampleEntities() + { + await using var ctx = NewContext(); + var repo = new KpiHistoryRepository(ctx); + await repo.RecordSamplesAsync(new[] + { + Sample("NotificationOutbox", "queueDepth", "Global", null, 5, Base.AddMinutes(10)), + Sample("NotificationOutbox", "queueDepth", "Global", null, 7, Base.AddMinutes(50)), + }); + ctx.ChangeTracker.Clear(); // isolate the fold's tracking behavior from the seed + + await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(1)); + + // The fold READS samples and WRITES rollups; the read must not register + // thousands of read-only KpiSample entries for DetectChanges to re-scan + // (arch-review 04 round 2, R2). + Assert.Empty(ctx.ChangeTracker.Entries()); + } + // Local mirror of the repo's private hour-truncation, for cutoff assertions. private static DateTime TruncateHour(DateTime utc) => new(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc);