feat(kpihistory): one-shot rollup backfill of existing samples on recorder start (plan #22 T6)

Fold the whole raw-retention window of already-recorded KpiSample rows into
KpiRollupHourly once shortly after the recorder singleton starts, so long-range
(30 d/90 d) trend charts aren't blank until enough wall-clock passes after
deploy. Reuses Task 3's idempotent FoldHourlyRollupsAsync over
[TruncateToHour(now) - RetentionDays, TruncateToHour(now)); runs at most once
per actor lifetime (_backfillDone latch) and defers past / blocks the periodic
lookback fold via a dedicated _backfillInFlight guard so the two idempotent
upserts never race on overlapping rows. Best-effort: no exception escapes; logs
start, window, and elapsed on completion. No Host or options change.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 12:07:47 -04:00
parent a10170f365
commit bedf3c55f0
2 changed files with 255 additions and 2 deletions
@@ -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));
}
}