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(