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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user