diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs
index 4ac9c74c..1dca2278 100644
--- a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs
@@ -31,7 +31,12 @@ namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
/// 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.
+/// same idempotent fold and runs at most once per actor lifetime. The backfill is
+/// sliced into bounded 24 h fold windows (oldest-first) rather than one unbounded
+/// pass, and lowers its in-flight guard between slices so periodic folds interleave
+/// — periodic folds and backfill slices strictly alternate through the single
+/// _rollupInFlight/_backfillInFlight gate pair, so the two idempotent
+/// upserts never race on overlapping rows (arch-review 04 round 2, R1).
///
///
///
@@ -118,11 +123,32 @@ 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.
+ /// Width of one backfill fold slice (24 h). The repository fold materializes its whole
+ /// window in memory, so the one-shot backfill must never hand it the full raw-retention
+ /// window (90 d ≈ tens of millions of rows at fleet volume — arch-review 04 round 2, R1).
+ /// One day per fold bounds each pass to roughly what a steady-state day writes, and the
+ /// guard is lowered between slices so periodic folds interleave instead of stalling
+ /// behind the historical pass.
+ ///
+ private static readonly TimeSpan BackfillSliceWidth = TimeSpan.FromHours(24);
+
+ ///
+ /// Queue of remaining backfill fold slices (each a bounded
+ /// window), oldest-first. Built once when the backfill plan is enumerated and drained one
+ /// slice per fold; a periodic may claim the fold path between slices
+ /// (the two idempotent upserts strictly alternate through the
+ /// / gate pair, never racing on
+ /// overlapping rows — arch-review 04 round 2, R1).
+ ///
+ private readonly Queue<(DateTime FromHourUtc, DateTime ToHourUtc)> _backfillSlices = new();
+
+ ///
+ /// In-flight guard for a single backfill fold slice. Set true when a slice fold is launched
+ /// and cleared when its arrives. While true, periodic
+ /// s are skipped (like ) so a backfill
+ /// slice never runs concurrently with a periodic lookback fold on the same upsert rows; it is
+ /// lowered BETWEEN slices so periodic folds interleave instead of stalling behind the whole
+ /// historical pass.
///
private bool _backfillInFlight;
@@ -160,10 +186,20 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
Receive(_ => HandleRollupTick());
Receive(_ => _rollupInFlight = false); // lower the in-flight guard (success or fault)
Receive(_ => HandleBackfillTick());
- Receive(_ =>
+ Receive(_ =>
{
- _backfillInFlight = false; // lower the in-flight guard (success or fault)
- _backfillDone = true; // latch: at most once per actor lifetime
+ _backfillInFlight = false; // release the fold path BETWEEN slices (R1)
+ if (_backfillSlices.Count == 0)
+ {
+ _backfillDone = true; // latch: at most once per actor lifetime (unchanged semantics)
+ _logger.LogInformation("KPI rollup backfill completed — all slices folded.");
+ return;
+ }
+
+ // Next slice via the timer, not inline: a RollupTick already queued in the mailbox
+ // is processed first, so periodic folds interleave; the existing _rollupInFlight
+ // deferral branch in HandleBackfillTick then re-arms the backfill behind it.
+ Timers.StartSingleTimer(BackfillTimerKey, BackfillTick.Instance, TimeSpan.Zero);
});
}
@@ -483,13 +519,15 @@ 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
+ /// has already completed () or a slice 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.
+ /// timer, so a backfill slice and the periodic lookback fold never race on overlapping upsert
+ /// rows. When no slice plan exists yet it enumerates the raw-retention window
+ /// [TruncateToHour(now) − RetentionDays, TruncateToHour(now)) into bounded
+ /// windows (oldest-first) and self-arms to start draining;
+ /// otherwise it dequeues ONE slice, raises the guard, and folds it off the actor thread,
+ /// piping a back to release the guard between slices.
///
private void HandleBackfillTick()
{
@@ -500,38 +538,80 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
if (_rollupInFlight)
{
- // A periodic fold is in flight; defer the one-shot backfill briefly so the two
+ // A periodic fold is in flight; defer the next backfill slice 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;
}
+ if (_backfillSlices.Count == 0)
+ {
+ // Build the slice plan once: the full raw-retention window in bounded 24 h folds,
+ // oldest-first. Never hand the repository fold the whole window at once (R1).
+ var toHourUtc = TruncateToHour(DateTime.UtcNow);
+ var fromHourUtc = toHourUtc - TimeSpan.FromDays(_options.RetentionDays);
+ EnqueueBackfillSlices(fromHourUtc, toHourUtc);
+
+ if (_backfillSlices.Count == 0)
+ {
+ // Degenerate window (no whole hours to fold) — nothing to backfill.
+ _backfillDone = true;
+ return;
+ }
+
+ _logger.LogInformation(
+ "KPI rollup backfill starting — folding existing samples in [{FromHour:o}, {ToHour:o}) "
+ + "({RetentionDays}-day window) across {SliceCount} slice(s) of up to {SliceHours} h.",
+ fromHourUtc, toHourUtc, _options.RetentionDays, _backfillSlices.Count, BackfillSliceWidth.TotalHours);
+
+ // Start draining via the timer (uniform path with the between-slice re-arm).
+ Timers.StartSingleTimer(BackfillTimerKey, BackfillTick.Instance, TimeSpan.Zero);
+ return;
+ }
+
+ // Dequeue and fold exactly ONE bounded slice.
+ var slice = _backfillSlices.Dequeue();
_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(
+ RunBackfillPass(slice.FromHourUtc, slice.ToHourUtc, cancellationToken).PipeTo(
Self,
- success: () => BackfillComplete.Instance,
+ success: () => BackfillSliceComplete.Instance,
failure: ex =>
{
- _logger.LogError(ex, "KPI rollup backfill faulted unexpectedly.");
- return BackfillComplete.Instance;
+ _logger.LogError(ex, "KPI rollup backfill slice faulted unexpectedly.");
+ return BackfillSliceComplete.Instance;
});
}
///
- /// Runs the one-shot backfill fold: opens a DI scope, resolves the repository, and folds the
+ /// Enumerates [, ) into
+ /// contiguous windows (the last possibly shorter),
+ /// oldest-first, and enqueues them onto .
+ ///
+ private void EnqueueBackfillSlices(DateTime fromHourUtc, DateTime toHourUtc)
+ {
+ var sliceStart = fromHourUtc;
+ while (sliceStart < toHourUtc)
+ {
+ var sliceEnd = sliceStart + BackfillSliceWidth;
+ if (sliceEnd > toHourUtc)
+ {
+ sliceEnd = toHourUtc;
+ }
+
+ _backfillSlices.Enqueue((sliceStart, sliceEnd));
+ sliceStart = sliceEnd;
+ }
+ }
+
+ ///
+ /// Runs one backfill fold slice: 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.
+ /// the hourly rollup table via the same idempotent upsert the periodic fold uses. 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 each slice is logged at Debug with its
+ /// window and elapsed wall time (the queue-drained completion is logged at Information).
///
private async Task RunBackfillPass(
DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken)
@@ -543,8 +623,8 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
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.",
+ _logger.LogDebug(
+ "KPI rollup backfill slice folded [{FromHour:o}, {ToHour:o}) in {ElapsedMs:F0} ms.",
fromHourUtc, toHourUtc, (DateTime.UtcNow - startedAt).TotalMilliseconds);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -614,9 +694,11 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
}
///
- /// 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.
+ /// Self-tick triggering the one-shot backfill: on the first tick it enumerates the full
+ /// raw-retention window into bounded slices, and each subsequent tick folds one slice. Armed
+ /// once via a single timer in (and re-armed to drain the next slice or
+ /// to defer past an in-flight periodic fold); the whole pass runs at most once per actor
+ /// lifetime.
///
internal sealed class BackfillTick
{
@@ -625,13 +707,14 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
}
///
- /// 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
+ /// Piped-back completion of ONE backfill fold slice; lets the slice run off the actor thread
+ /// and, on the actor thread, lowers the _backfillInFlight guard BETWEEN slices — then
+ /// either drains the next slice (self-armed timer) or, when the queue is empty, latches
/// _backfillDone (fires on success and fault).
///
- internal sealed class BackfillComplete
+ internal sealed class BackfillSliceComplete
{
- public static readonly BackfillComplete Instance = new();
- private BackfillComplete() { }
+ public static readonly BackfillSliceComplete Instance = new();
+ private BackfillSliceComplete() { }
}
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs
index 1a50ef4a..392cdccc 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs
@@ -111,6 +111,7 @@ public class KpiHistoryRecorderActorTests : TestKit
private DateTime? _foldFromHourUtc;
private DateTime? _foldToHourUtc;
private int _foldCount;
+ private readonly List<(DateTime FromHourUtc, DateTime ToHourUtc)> _foldWindows = new();
public IReadOnlyList Recorded
{
@@ -148,6 +149,12 @@ public class KpiHistoryRecorderActorTests : TestKit
get { lock (_gate) { return _foldCount; } }
}
+ /// Every fold window handed to , in call order.
+ public IReadOnlyList<(DateTime FromHourUtc, DateTime ToHourUtc)> FoldWindows
+ {
+ get { lock (_gate) { return _foldWindows.ToArray(); } }
+ }
+
public Task RecordSamplesAsync(
IReadOnlyCollection samples, CancellationToken cancellationToken = default)
{
@@ -176,6 +183,7 @@ public class KpiHistoryRecorderActorTests : TestKit
{
_foldFromHourUtc = fromHourUtc;
_foldToHourUtc = toHourUtc;
+ _foldWindows.Add((fromHourUtc, toHourUtc));
_foldCount++;
}
return Task.CompletedTask;
@@ -354,6 +362,89 @@ public class KpiHistoryRecorderActorTests : TestKit
Task.FromResult(null);
}
+ ///
+ /// Repository fake for the backfill-slice interleaving test. Classifies each fold as a backfill
+ /// slice (24 h window) or a periodic fold (the distinct RollupLookbackHours-wide window)
+ /// by window width. Backfill slices complete immediately (counted); the FIRST periodic fold
+ /// blocks in flight until released, so the test can observe that a periodic fold claimed the
+ /// fold path between backfill slices while backfill slices remained.
+ ///
+ private sealed class InterleaveFoldRepository : IKpiHistoryRepository
+ {
+ private readonly TimeSpan _periodicWindowWidth;
+ private readonly object _gate = new();
+ private readonly SemaphoreSlim _firstPeriodicRelease = new(initialCount: 0);
+ private int _backfillFoldCount;
+ private int _periodicFoldCount;
+ private int _backfillCountAtFirstPeriodic = -1;
+
+ public InterleaveFoldRepository(TimeSpan periodicWindowWidth) =>
+ _periodicWindowWidth = periodicWindowWidth;
+
+ /// Number of backfill-slice folds (24 h windows) entered so far.
+ public int BackfillFoldCount { get { lock (_gate) { return _backfillFoldCount; } } }
+
+ /// Number of periodic folds (the lookback-width window) entered so far.
+ public int PeriodicFoldCount { get { lock (_gate) { return _periodicFoldCount; } } }
+
+ /// Backfill-slice count observed at the instant the first periodic fold entered.
+ public int BackfillCountAtFirstPeriodic { get { lock (_gate) { return _backfillCountAtFirstPeriodic; } } }
+
+ /// Releases the first (held-in-flight) periodic fold so the deferred backfill resumes.
+ public void ReleaseFirstPeriodic() => _firstPeriodicRelease.Release();
+
+ public Task RecordSamplesAsync(
+ IReadOnlyCollection samples, CancellationToken cancellationToken = default) =>
+ Task.CompletedTask;
+
+ public Task> GetRawSeriesAsync(
+ string source, string metric, string scope, string? scopeKey,
+ DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
+ Task.FromResult>(Array.Empty());
+
+ public Task PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) =>
+ Task.FromResult(0);
+
+ public Task FoldHourlyRollupsAsync(
+ DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default)
+ {
+ var isPeriodic = (toHourUtc - fromHourUtc) == _periodicWindowWidth;
+ if (isPeriodic)
+ {
+ bool blockThis;
+ lock (_gate)
+ {
+ _periodicFoldCount++;
+ blockThis = _periodicFoldCount == 1;
+ if (blockThis)
+ {
+ _backfillCountAtFirstPeriodic = _backfillFoldCount;
+ }
+ }
+
+ // Hold the first periodic fold in flight (on a threadpool thread — PipeTo runs the
+ // pass off the actor thread) so the between-slice interleave state is observable.
+ return blockThis
+ ? Task.Run(() => _firstPeriodicRelease.Wait(cancellationToken), cancellationToken)
+ : Task.CompletedTask;
+ }
+
+ lock (_gate) { _backfillFoldCount++; }
+ return Task.CompletedTask;
+ }
+
+ public Task> GetHourlySeriesAsync(
+ string source, string metric, string scope, string? scopeKey,
+ DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
+ Task.FromResult>(Array.Empty());
+
+ public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) =>
+ Task.CompletedTask;
+
+ public Task GetLatestRollupHourAsync(CancellationToken cancellationToken = default) =>
+ Task.FromResult(null);
+ }
+
private IServiceProvider BuildServiceProvider(
IKpiHistoryRepository repository, params IKpiSampleSource[] sources)
{
@@ -683,13 +774,15 @@ public class KpiHistoryRecorderActorTests : TestKit
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).
+ // (30 d/90 d) charts aren't blank until enough wall-clock passes after deploy. It now
+ // does so in bounded <=24 h slices (R1) rather than a single unbounded fold: the union
+ // of the slice windows must cover exactly [truncate(now) − RetentionDays, truncate(now)),
+ // each slice must be <=24 h, and the slices must be contiguous and oldest-first. A second
+ // BackfillTick to the same actor must be a no-op (the _backfillDone latch makes the whole
+ // pass run at most once per actor lifetime).
var repository = new RecordingRepository();
var sp = BuildServiceProvider(repository, new HealthySource());
- const int retentionDays = 90;
+ const int retentionDays = 5;
var actor = CreateActor(sp, new KpiHistoryOptions
{
SampleInterval = TimeSpan.FromHours(1),
@@ -703,52 +796,168 @@ public class KpiHistoryRecorderActorTests : TestKit
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 windows = repository.FoldWindows;
+ // RetentionDays = 5 → exactly 5 contiguous 24 h slices.
+ Assert.Equal(retentionDays, windows.Count);
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}");
+ var expectedFromHour = expectedToHour - TimeSpan.FromDays(retentionDays);
- // fromHour is exactly RetentionDays (the full raw-retention window) before toHour.
- Assert.Equal(toHour - TimeSpan.FromDays(retentionDays), fromHour);
+ // Oldest-first, contiguous, each <=24 h, union == full retention window.
+ Assert.Equal(expectedFromHour, windows[0].FromHourUtc);
+ Assert.Equal(expectedToHour, windows[^1].ToHourUtc);
+ for (var i = 0; i < windows.Count; i++)
+ {
+ Assert.True(windows[i].ToHourUtc - windows[i].FromHourUtc <= TimeSpan.FromHours(24),
+ $"slice {i} width {windows[i].ToHourUtc - windows[i].FromHourUtc} exceeds 24 h");
+ if (i > 0)
+ {
+ Assert.Equal(windows[i - 1].ToHourUtc, windows[i].FromHourUtc); // contiguous
+ }
+ }
},
- duration: TimeSpan.FromSeconds(3),
+ duration: TimeSpan.FromSeconds(5),
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.)
+ // Second BackfillTick to the SAME actor: the once-only latch must suppress the whole pass,
+ // so no further slices are folded. (No periodic RollupTick is sent, and the periodic/backfill
+ // auto-timers don't fire within this window, so the fold windows are driven solely by the
+ // backfill under test.)
+ var foldedCount = repository.FoldWindows.Count;
actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);
Thread.Sleep(300);
- Assert.Equal(1, repository.FoldCount);
+ Assert.Equal(foldedCount, repository.FoldWindows.Count);
+ }
+
+ [Fact]
+ public void Backfill_SlicesRetentionWindow_IntoBoundedDayFolds_OldestFirst()
+ {
+ // RetentionDays = 3 → 3 fold calls, each window <=24 h, contiguous, oldest-first, whose
+ // union is exactly [TruncateToHour(now) − 3 d, TruncateToHour(now)). Pre-R1 a single fold
+ // call spanned the whole 3-day window (arch-review 04 round 2, R1).
+ var repository = new RecordingRepository();
+ var sp = BuildServiceProvider(repository, new HealthySource());
+ const int retentionDays = 3;
+ 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(
+ () =>
+ {
+ var windows = repository.FoldWindows;
+ Assert.Equal(retentionDays, windows.Count); // 3 × 24 h slices
+
+ var expectedToHour = new DateTime(
+ DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day,
+ DateTime.UtcNow.Hour, 0, 0, DateTimeKind.Utc);
+ var expectedFromHour = expectedToHour - TimeSpan.FromDays(retentionDays);
+
+ Assert.Equal(expectedFromHour, windows[0].FromHourUtc); // oldest first
+ Assert.Equal(expectedToHour, windows[^1].ToHourUtc); // newest last
+ for (var i = 0; i < windows.Count; i++)
+ {
+ Assert.True(windows[i].ToHourUtc - windows[i].FromHourUtc <= TimeSpan.FromHours(24),
+ $"slice {i} exceeds 24 h");
+ if (i > 0)
+ {
+ Assert.Equal(windows[i - 1].ToHourUtc, windows[i].FromHourUtc); // contiguous
+ }
+ }
+ },
+ duration: TimeSpan.FromSeconds(5),
+ interval: TimeSpan.FromMilliseconds(50));
+ }
+
+ [Fact]
+ public async Task PeriodicRollupTick_Interleaves_BetweenBackfillSlices()
+ {
+ // A periodic fold must be able to claim the fold path BETWEEN backfill slices: while the
+ // historical backfill runs slice-by-slice, a RollupTick that lands in a between-slice gap
+ // runs its (recent-window) fold and the remaining backfill slices defer behind it, then
+ // ALL backfill slices still complete afterwards. Pre-R1 _backfillInFlight blocked every
+ // periodic fold for the whole (single-fold) pass, so no interleaving was possible
+ // (arch-review 04 round 2, R1).
+ const int retentionDays = 8; // 8 backfill slices of 24 h
+ const int lookbackHours = 2; // periodic fold window width — distinct from the 24 h slices
+ var repository = new InterleaveFoldRepository(TimeSpan.FromHours(lookbackHours));
+ var sp = BuildServiceProvider(repository, new HealthySource());
+ var actor = CreateActor(sp, new KpiHistoryOptions
+ {
+ SampleInterval = TimeSpan.FromHours(1),
+ PurgeInterval = TimeSpan.FromHours(1),
+ RollupInterval = TimeSpan.FromHours(24), // periodic auto-timer won't fire during the test
+ RetentionDays = retentionDays,
+ RollupLookbackHours = lookbackHours,
+ });
+
+ actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);
+
+ // Continuously offer periodic ticks; one will win a between-slice gap and interleave.
+ using var stopFlood = new CancellationTokenSource();
+ var flood = Task.Run(() =>
+ {
+ while (!stopFlood.IsCancellationRequested)
+ {
+ actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
+ Thread.Sleep(1);
+ }
+ });
+
+ try
+ {
+ // A periodic fold interleaved BEFORE the backfill finished all its slices.
+ AwaitAssert(
+ () =>
+ {
+ Assert.True(repository.PeriodicFoldCount >= 1, "no periodic fold interleaved");
+ Assert.True(
+ repository.BackfillCountAtFirstPeriodic < retentionDays,
+ "the periodic fold did not interleave before the backfill completed");
+ },
+ duration: TimeSpan.FromSeconds(10),
+ interval: TimeSpan.FromMilliseconds(20));
+ }
+ finally
+ {
+ stopFlood.Cancel();
+ await flood;
+ }
+
+ // The first periodic fold is held in flight, deferring the remaining backfill slices;
+ // release it so the deferred backfill resumes and ALL slices still complete.
+ repository.ReleaseFirstPeriodic();
+ AwaitAssert(
+ () => Assert.Equal(retentionDays, repository.BackfillFoldCount),
+ duration: TimeSpan.FromSeconds(10),
+ interval: TimeSpan.FromMilliseconds(50));
}
[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.
+ // While a backfill slice fold is in flight, a periodic RollupTick must be skipped so the
+ // two idempotent upserts never race on overlapping rows (unchanged semantics — a RollupTick
+ // arriving while a *slice* is in flight is still skipped). With a gated fold holding the
+ // single backfill slice open, a RollupTick must NOT enter a second fold — the fold count
+ // stays at 1 until the gate is released. RetentionDays = 1 → exactly one 24 h backfill slice.
var repository = new GatedFoldRepository();
var sp = BuildServiceProvider(repository, new HealthySource());
- var actor = CreateActor(sp);
+ var actor = CreateActor(sp, new KpiHistoryOptions
+ {
+ SampleInterval = TimeSpan.FromHours(1),
+ PurgeInterval = TimeSpan.FromHours(1),
+ RollupInterval = TimeSpan.FromHours(1),
+ RetentionDays = 1,
+ });
// Backfill tick: raises the backfill guard, enters the (gated, blocking) fold — held in flight.
actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);
@@ -765,7 +974,7 @@ public class KpiHistoryRecorderActorTests : TestKit
// 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.
+ // we don't race the asynchronous BackfillSliceComplete that lowers the guard.
repository.Release();
AwaitAssert(
() =>