fix(kpi): slice the rollup backfill into bounded 24h folds that yield to periodic folds (plan R2-04 T3)

This commit is contained in:
Joseph Doherty
2026-07-13 09:57:42 -04:00
parent a1c7c4ef26
commit 350d530135
2 changed files with 367 additions and 75 deletions
@@ -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<KpiSample> Recorded
{
@@ -148,6 +149,12 @@ public class KpiHistoryRecorderActorTests : TestKit
get { lock (_gate) { return _foldCount; } }
}
/// <summary>Every fold window handed to <see cref="FoldHourlyRollupsAsync"/>, in call order.</summary>
public IReadOnlyList<(DateTime FromHourUtc, DateTime ToHourUtc)> FoldWindows
{
get { lock (_gate) { return _foldWindows.ToArray(); } }
}
public Task RecordSamplesAsync(
IReadOnlyCollection<KpiSample> 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<DateTime?>(null);
}
/// <summary>
/// Repository fake for the backfill-slice interleaving test. Classifies each fold as a backfill
/// slice (24 h window) or a periodic fold (the distinct <c>RollupLookbackHours</c>-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.
/// </summary>
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;
/// <summary>Number of backfill-slice folds (24 h windows) entered so far.</summary>
public int BackfillFoldCount { get { lock (_gate) { return _backfillFoldCount; } } }
/// <summary>Number of periodic folds (the lookback-width window) entered so far.</summary>
public int PeriodicFoldCount { get { lock (_gate) { return _periodicFoldCount; } } }
/// <summary>Backfill-slice count observed at the instant the first periodic fold entered.</summary>
public int BackfillCountAtFirstPeriodic { get { lock (_gate) { return _backfillCountAtFirstPeriodic; } } }
/// <summary>Releases the first (held-in-flight) periodic fold so the deferred backfill resumes.</summary>
public void ReleaseFirstPeriodic() => _firstPeriodicRelease.Release();
public Task RecordSamplesAsync(
IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());
public Task<int> 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<IReadOnlyList<KpiSeriesPoint>> GetHourlySeriesAsync(
string source, string metric, string scope, string? scopeKey,
DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<KpiSeriesPoint>>(Array.Empty<KpiSeriesPoint>());
public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<DateTime?>(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(
() =>