diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs
index 7a2cd7bd..4ac9c74c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs
@@ -28,7 +28,10 @@ namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
/// KpiRollupHourly table via
/// (idempotent upsert,
/// in-progress hour excluded), so long-range trend charts read a pre-thinned
-/// series.
+/// series. A one-shot backfill timer additionally folds the whole raw-retention
+/// window of already-recorded samples once shortly after start, so 30 d/90 d
+/// charts aren't blank until enough wall-clock passes after deploy; it reuses the
+/// same idempotent fold and runs at most once per actor lifetime.
///
///
///
@@ -66,6 +69,18 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
private const string SampleTimerKey = "kpi-sample";
private const string PurgeTimerKey = "kpi-purge";
private const string RollupTimerKey = "kpi-rollup";
+ private const string BackfillTimerKey = "kpi-rollup-backfill";
+
+ ///
+ /// Initial delay before the one-shot rollup backfill fires (see
+ /// ). Deliberately a touch longer than the periodic rollup
+ /// timer's own 10 s initial delay so that, when both are armed at start, the periodic
+ /// tick claims the fold path first and the backfill simply defers (the two folds are
+ /// idempotent upserts, but they must not race on overlapping rows). Also comfortably beyond
+ /// the sub-second/second-scale AwaitAssert windows used by the unit tests, so the
+ /// one-shot never auto-fires mid-test — tests drive by hand.
+ ///
+ private static readonly TimeSpan BackfillInitialDelay = TimeSpan.FromSeconds(15);
private readonly IServiceProvider _serviceProvider;
private readonly KpiHistoryOptions _options;
@@ -102,6 +117,24 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
///
private bool _rollupInFlight;
+ ///
+ /// In-flight guard for the one-shot backfill fold. Set true when the backfill fold is
+ /// launched and cleared when its arrives. While true, periodic
+ /// s are skipped (like ) so the (possibly
+ /// long) full-retention-window backfill never runs concurrently with a periodic lookback fold
+ /// on the same upsert rows.
+ ///
+ private bool _backfillInFlight;
+
+ ///
+ /// Once-only latch for the backfill. Set true when the backfill fold completes (success or
+ /// fault) so it runs at most once per actor lifetime — a fresh singleton on failover starts a
+ /// new instance and re-runs the (idempotent) backfill, which is the intended self-heal. Set on
+ /// completion rather than launch so that if the actor is torn down mid-backfill the next
+ /// lifetime still attempts it.
+ ///
+ private bool _backfillDone;
+
/// Akka timer scheduler, assigned by the actor system via .
public ITimerScheduler Timers { get; set; } = null!;
@@ -126,6 +159,12 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
Receive(_ => { }); // best-effort: no actor state to reset on completion
Receive(_ => HandleRollupTick());
Receive(_ => _rollupInFlight = false); // lower the in-flight guard (success or fault)
+ Receive(_ => HandleBackfillTick());
+ Receive(_ =>
+ {
+ _backfillInFlight = false; // lower the in-flight guard (success or fault)
+ _backfillDone = true; // latch: at most once per actor lifetime
+ });
}
///
@@ -161,6 +200,17 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
RollupTick.Instance,
initialDelay: TimeSpan.FromSeconds(10),
interval: _options.RollupInterval);
+
+ // One-shot backfill: fold the whole raw-retention window of existing samples into the
+ // hourly rollup table once shortly after start, so long-range (30 d/90 d) trend charts
+ // aren't blank until enough wall-clock passes after deploy. The periodic tick only folds
+ // the trailing RollupLookbackHours, so without this the deep history stays unrolled. The
+ // fold is an idempotent upsert, so re-running on a failover (a fresh singleton re-arms
+ // this timer) is safe.
+ Timers.StartSingleTimer(
+ BackfillTimerKey,
+ BackfillTick.Instance,
+ timeout: BackfillInitialDelay);
}
///
@@ -382,8 +432,11 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
///
private void HandleRollupTick()
{
- if (_rollupInFlight)
+ if (_rollupInFlight || _backfillInFlight)
{
+ // Skip while either a prior periodic fold OR the one-shot full-window backfill is
+ // running, so two idempotent upserts never race on overlapping rows. A skipped
+ // periodic tick self-heals on the next fold (it re-processes the lookback window).
_logger.LogDebug("KPI rollup tick skipped — a rollup fold is already in flight.");
return;
}
@@ -428,6 +481,82 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
}
}
+ ///
+ /// Handles the one-shot backfill tick. Runs at most once per actor lifetime: if the backfill
+ /// has already completed () or is in flight
+ /// () the tick is ignored. If a periodic fold currently holds
+ /// the fold path () the backfill defers by re-arming its single
+ /// timer, so the full-window backfill and the periodic lookback fold never race on overlapping
+ /// upsert rows. Otherwise it raises the backfill guard and folds the whole raw-retention
+ /// window [TruncateToHour(now) − RetentionDays, TruncateToHour(now)) off the actor
+ /// thread, piping a back to lower the guard and set the latch.
+ ///
+ private void HandleBackfillTick()
+ {
+ if (_backfillDone || _backfillInFlight)
+ {
+ return;
+ }
+
+ if (_rollupInFlight)
+ {
+ // A periodic fold is in flight; defer the one-shot backfill briefly so the two
+ // idempotent upserts don't contend on overlapping rows. Re-arm the single timer.
+ Timers.StartSingleTimer(BackfillTimerKey, BackfillTick.Instance, TimeSpan.FromSeconds(5));
+ return;
+ }
+
+ _backfillInFlight = true;
+ var toHourUtc = TruncateToHour(DateTime.UtcNow);
+ var fromHourUtc = toHourUtc - TimeSpan.FromDays(_options.RetentionDays);
+ var cancellationToken = _shutdownCts?.Token ?? CancellationToken.None;
+
+ _logger.LogInformation(
+ "KPI rollup backfill starting — folding existing samples in [{FromHour:o}, {ToHour:o}) ({RetentionDays}-day window).",
+ fromHourUtc, toHourUtc, _options.RetentionDays);
+
+ RunBackfillPass(fromHourUtc, toHourUtc, cancellationToken).PipeTo(
+ Self,
+ success: () => BackfillComplete.Instance,
+ failure: ex =>
+ {
+ _logger.LogError(ex, "KPI rollup backfill faulted unexpectedly.");
+ return BackfillComplete.Instance;
+ });
+ }
+
+ ///
+ /// Runs the one-shot backfill fold: opens a DI scope, resolves the repository, and folds the
+ /// complete hours in [, ) into
+ /// the hourly rollup table via the same idempotent upsert the periodic fold uses (Task 3). The
+ /// whole body is wrapped so the returned task never faults — best-effort observability must
+ /// never disrupt startup. The fold method returns no count, so completion is logged with the
+ /// window and elapsed wall time only.
+ ///
+ private async Task RunBackfillPass(
+ DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken)
+ {
+ var startedAt = DateTime.UtcNow;
+ try
+ {
+ await using var scope = _serviceProvider.CreateAsyncScope();
+ var repository = scope.ServiceProvider.GetRequiredService();
+ await repository.FoldHourlyRollupsAsync(fromHourUtc, toHourUtc, cancellationToken);
+
+ _logger.LogInformation(
+ "KPI rollup backfill completed — folded [{FromHour:o}, {ToHour:o}) in {ElapsedMs:F0} ms.",
+ fromHourUtc, toHourUtc, (DateTime.UtcNow - startedAt).TotalMilliseconds);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Shutdown interrupted the backfill; a failover re-runs it (idempotent). Not a failure.
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "KPI rollup backfill failed unexpectedly.");
+ }
+ }
+
///
/// Truncates a UTC instant to the start of its hour (minutes/seconds/ticks dropped),
/// preserving . Used to derive the fold window's whole-hour
@@ -483,4 +612,26 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
public static readonly RollupComplete Instance = new();
private RollupComplete() { }
}
+
+ ///
+ /// Self-tick triggering the one-shot backfill fold over the full raw-retention window. Armed
+ /// once via a single timer in (and re-armed only to defer past an
+ /// in-flight periodic fold); runs at most once per actor lifetime.
+ ///
+ internal sealed class BackfillTick
+ {
+ public static readonly BackfillTick Instance = new();
+ private BackfillTick() { }
+ }
+
+ ///
+ /// Piped-back completion of the one-shot backfill fold; lets the fold run off the actor thread
+ /// and, on the actor thread, lowers the _backfillInFlight guard and latches
+ /// _backfillDone (fires on success and fault).
+ ///
+ internal sealed class BackfillComplete
+ {
+ public static readonly BackfillComplete Instance = new();
+ private BackfillComplete() { }
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs
index f17e2125..7a98c068 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs
@@ -662,4 +662,106 @@ public class KpiHistoryRecorderActorTests : TestKit
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
+
+ // =====================================================================
+ // One-shot rollup backfill (Task 6)
+ // =====================================================================
+
+ [Fact]
+ public void BackfillTick_FoldsFullRetentionWindow_Once()
+ {
+ // The one-shot backfill must fold the WHOLE raw-retention window once, so long-range
+ // (30 d/90 d) charts aren't blank until enough wall-clock passes after deploy: the fold's
+ // upper bound is the current hour start (in-progress hour excluded) and its lower bound is
+ // RetentionDays earlier. A second BackfillTick to the same actor must be a no-op (the
+ // _backfillDone latch makes it run at most once per actor lifetime).
+ var repository = new RecordingRepository();
+ var sp = BuildServiceProvider(repository, new HealthySource());
+ const int retentionDays = 90;
+ var actor = CreateActor(sp, new KpiHistoryOptions
+ {
+ SampleInterval = TimeSpan.FromHours(1),
+ PurgeInterval = TimeSpan.FromHours(1),
+ RollupInterval = TimeSpan.FromHours(1),
+ RetentionDays = retentionDays,
+ });
+
+ actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);
+
+ AwaitAssert(
+ () =>
+ {
+ Assert.Equal(1, repository.FoldCount);
+ Assert.NotNull(repository.FoldToHourUtc);
+ Assert.NotNull(repository.FoldFromHourUtc);
+
+ var toHour = repository.FoldToHourUtc!.Value;
+ var fromHour = repository.FoldFromHourUtc!.Value;
+
+ // toHour is the truncated current hour (in-progress hour excluded).
+ Assert.Equal(DateTimeKind.Utc, toHour.Kind);
+ Assert.Equal(0, toHour.Minute);
+ Assert.Equal(0, toHour.Second);
+ Assert.Equal(0, toHour.Millisecond);
+
+ var expectedToHour = new DateTime(
+ DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day,
+ DateTime.UtcNow.Hour, 0, 0, DateTimeKind.Utc);
+ // Within one hour of "now truncated" (guards a rare hour-boundary crossing mid-test).
+ Assert.True(
+ Math.Abs((toHour - expectedToHour).TotalHours) <= 1.0,
+ $"backfill toHour {toHour:o} should be the current hour start {expectedToHour:o}");
+
+ // fromHour is exactly RetentionDays (the full raw-retention window) before toHour.
+ Assert.Equal(toHour - TimeSpan.FromDays(retentionDays), fromHour);
+ },
+ duration: TimeSpan.FromSeconds(3),
+ interval: TimeSpan.FromMilliseconds(50));
+
+ // Second BackfillTick to the SAME actor: the once-only latch must suppress it, so the
+ // fold count stays at 1. (No periodic RollupTick is sent, and the periodic/backfill
+ // auto-timers don't fire within this sub-10 s window, so FoldCount is driven solely by
+ // the two backfill ticks under test.)
+ actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);
+ Thread.Sleep(300);
+ Assert.Equal(1, repository.FoldCount);
+ }
+
+ [Fact]
+ public void BackfillInFlight_SkipsPeriodicRollupTick()
+ {
+ // While the (possibly long) full-window backfill fold is in flight, a periodic RollupTick
+ // must be skipped so the two idempotent upserts never race on overlapping rows. With a
+ // gated fold holding the backfill open, a RollupTick must NOT enter a second fold — the
+ // fold count stays at 1 until the gate is released.
+ var repository = new GatedFoldRepository();
+ var sp = BuildServiceProvider(repository, new HealthySource());
+ var actor = CreateActor(sp);
+
+ // Backfill tick: raises the backfill guard, enters the (gated, blocking) fold — held in flight.
+ actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);
+ AwaitAssert(
+ () => Assert.Equal(1, repository.FoldCount),
+ duration: TimeSpan.FromSeconds(3),
+ interval: TimeSpan.FromMilliseconds(50));
+
+ // Periodic rollup tick while the backfill is in flight: must be skipped by the guard.
+ actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
+ Assert.Equal(1, repository.FoldCount);
+ Thread.Sleep(300);
+ Assert.Equal(1, repository.FoldCount);
+
+ // Release the gate so the backfill completes and its guard is lowered; a fresh periodic
+ // tick must now run a new fold (guards correctly reset, not stuck). Re-send on each poll so
+ // we don't race the asynchronous BackfillComplete that lowers the guard.
+ repository.Release();
+ AwaitAssert(
+ () =>
+ {
+ actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
+ Assert.Equal(2, repository.FoldCount);
+ },
+ duration: TimeSpan.FromSeconds(3),
+ interval: TimeSpan.FromMilliseconds(50));
+ }
}