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