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
@@ -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
/// <c>_rollupInFlight</c>/<c>_backfillInFlight</c> gate pair, so the two idempotent
/// upserts never race on overlapping rows (arch-review 04 round 2, R1).
/// </summary>
/// <remarks>
/// <para>
@@ -118,11 +123,32 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
private bool _rollupInFlight;
/// <summary>
/// In-flight guard for the one-shot backfill fold. Set true when the backfill fold is
/// launched and cleared when its <see cref="BackfillComplete"/> arrives. While true, periodic
/// <see cref="RollupTick"/>s are skipped (like <see cref="_rollupInFlight"/>) 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.
/// </summary>
private static readonly TimeSpan BackfillSliceWidth = TimeSpan.FromHours(24);
/// <summary>
/// Queue of remaining backfill fold slices (each a bounded <see cref="BackfillSliceWidth"/>
/// window), oldest-first. Built once when the backfill plan is enumerated and drained one
/// slice per fold; a periodic <see cref="RollupTick"/> may claim the fold path between slices
/// (the two idempotent upserts strictly alternate through the
/// <see cref="_rollupInFlight"/>/<see cref="_backfillInFlight"/> gate pair, never racing on
/// overlapping rows — arch-review 04 round 2, R1).
/// </summary>
private readonly Queue<(DateTime FromHourUtc, DateTime ToHourUtc)> _backfillSlices = new();
/// <summary>
/// In-flight guard for a single backfill fold slice. Set true when a slice fold is launched
/// and cleared when its <see cref="BackfillSliceComplete"/> arrives. While true, periodic
/// <see cref="RollupTick"/>s are skipped (like <see cref="_rollupInFlight"/>) 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.
/// </summary>
private bool _backfillInFlight;
@@ -160,10 +186,20 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
Receive<RollupTick>(_ => HandleRollupTick());
Receive<RollupComplete>(_ => _rollupInFlight = false); // lower the in-flight guard (success or fault)
Receive<BackfillTick>(_ => HandleBackfillTick());
Receive<BackfillComplete>(_ =>
Receive<BackfillSliceComplete>(_ =>
{
_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
/// <summary>
/// Handles the one-shot backfill tick. Runs at most once per actor lifetime: if the backfill
/// has already completed (<see cref="_backfillDone"/>) or is in flight
/// has already completed (<see cref="_backfillDone"/>) or a slice is in flight
/// (<see cref="_backfillInFlight"/>) the tick is ignored. If a periodic fold currently holds
/// the fold path (<see cref="_rollupInFlight"/>) 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 <c>[TruncateToHour(now) RetentionDays, TruncateToHour(now))</c> off the actor
/// thread, piping a <see cref="BackfillComplete"/> 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
/// <c>[TruncateToHour(now) RetentionDays, TruncateToHour(now))</c> into bounded
/// <see cref="BackfillSliceWidth"/> 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 <see cref="BackfillSliceComplete"/> back to release the guard between slices.
/// </summary>
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;
});
}
/// <summary>
/// Runs the one-shot backfill fold: opens a DI scope, resolves the repository, and folds the
/// Enumerates <c>[<paramref name="fromHourUtc"/>, <paramref name="toHourUtc"/>)</c> into
/// contiguous <see cref="BackfillSliceWidth"/> windows (the last possibly shorter),
/// oldest-first, and enqueues them onto <see cref="_backfillSlices"/>.
/// </summary>
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;
}
}
/// <summary>
/// Runs one backfill fold slice: opens a DI scope, resolves the repository, and folds the
/// complete hours in <c>[<paramref name="fromHourUtc"/>, <paramref name="toHourUtc"/>)</c> 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).
/// </summary>
private async Task RunBackfillPass(
DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken)
@@ -543,8 +623,8 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
var repository = scope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
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
}
/// <summary>
/// Self-tick triggering the one-shot backfill fold over the full raw-retention window. Armed
/// once via a single timer in <see cref="PreStart"/> (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 <see cref="PreStart"/> (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.
/// </summary>
internal sealed class BackfillTick
{
@@ -625,13 +707,14 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Piped-back completion of the one-shot backfill fold; lets the fold run off the actor thread
/// and, on the actor thread, lowers the <c>_backfillInFlight</c> 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 <c>_backfillInFlight</c> guard BETWEEN slices — then
/// either drains the next slice (self-armed timer) or, when the queue is empty, latches
/// <c>_backfillDone</c> (fires on success and fault).
/// </summary>
internal sealed class BackfillComplete
internal sealed class BackfillSliceComplete
{
public static readonly BackfillComplete Instance = new();
private BackfillComplete() { }
public static readonly BackfillSliceComplete Instance = new();
private BackfillSliceComplete() { }
}
}