diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IKpiHistoryRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IKpiHistoryRepository.cs index 1ebb8a78..774f9a11 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IKpiHistoryRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IKpiHistoryRepository.cs @@ -109,6 +109,16 @@ public interface IKpiHistoryRepository string source, string metric, string scope, string? scopeKey, DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default); + /// + /// Returns the newest KpiRollupHourly.HourStartUtc across all series, or + /// null when no rollups exist. The recorder's one-shot backfill consults + /// this watermark to skip (or shrink to) the un-rolled tail instead of re-folding + /// the whole raw-retention window on every failover (arch-review 04 round 2, R1). + /// + /// Cancellation token. + /// A task resolving to the newest folded hour start, or null when empty. + Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default); + /// /// Bulk-deletes hourly rollup rows whose /// is diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs index 2f6fd26a..50bb4223 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/KpiHistoryRepository.cs @@ -192,6 +192,15 @@ public sealed class KpiHistoryRepository : IKpiHistoryRepository .ToListAsync(cancellationToken); } + /// + public async Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default) + { + // Single MAX over the unique IX_KpiRollupHourly_Series population (the rollup + // table is small — one row per series-hour). Null when no rollups exist. + return await _context.KpiRollupHourly + .MaxAsync(r => (DateTime?)r.HourStartUtc, cancellationToken); + } + /// public async Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) { 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 4aa216f8..3a815797 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/KpiHistoryRepositoryTests.cs @@ -406,6 +406,29 @@ public class KpiHistoryRepositoryTests Assert.Empty(ctx.ChangeTracker.Entries()); } + [Fact] + public async Task GetLatestRollupHour_ReturnsNull_WhenNoRollupsExist() + { + await using var ctx = NewContext(); + var repo = new KpiHistoryRepository(ctx); + Assert.Null(await repo.GetLatestRollupHourAsync()); + } + + [Fact] + public async Task GetLatestRollupHour_ReturnsNewestHourStart_AcrossAllSeries() + { + await using var ctx = NewContext(); + var repo = new KpiHistoryRepository(ctx); + await repo.RecordSamplesAsync(new[] + { + Sample("NotificationOutbox", "queueDepth", "Global", null, 5, Base.AddMinutes(10)), + Sample("SiteCallAudit", "buffered", "Global", null, 2, Base.AddHours(3).AddMinutes(10)), + }); + await repo.FoldHourlyRollupsAsync(Base, Base.AddHours(4)); + + Assert.Equal(Base.AddHours(3), await repo.GetLatestRollupHourAsync()); + } + // 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); diff --git a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs index 7a98c068..1a50ef4a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs @@ -191,6 +191,9 @@ public class KpiHistoryRecorderActorTests : TestKit lock (_gate) { _rollupPurgeCutoff = before; } return Task.CompletedTask; } + + public Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default) => + Task.FromResult(null); } /// @@ -237,6 +240,9 @@ public class KpiHistoryRecorderActorTests : TestKit public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default) => + Task.FromResult(null); } /// @@ -283,6 +289,9 @@ public class KpiHistoryRecorderActorTests : TestKit public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default) => + Task.FromResult(null); } /// @@ -340,6 +349,9 @@ public class KpiHistoryRecorderActorTests : TestKit public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default) => + Task.FromResult(null); } private IServiceProvider BuildServiceProvider(