feat(kpihistory): hourly rollup recorder tick + rollup purge + options (plan #22 T4)

Add a third Akka periodic timer (kpi-rollup) to KpiHistoryRecorderActor that
folds the trailing RollupLookbackHours of raw KpiSample rows into the hourly
KpiRollupHourly table via the idempotent FoldHourlyRollupsAsync upsert, with the
in-progress hour excluded (toHourUtc = current hour start, exclusive). A
_rollupInFlight guard coalesces overlapping ticks (mirrors _sampleInFlight), the
fold is best-effort (no exception escapes a tick), and a missed/failover tick
self-heals via the lookback re-fold. The daily purge now runs BOTH the raw
PurgeOlderThanAsync (RetentionDays) and PurgeRollupsOlderThanAsync
(RollupRetentionDays), isolated so one failure never skips the other.

New KpiHistoryOptions: RollupInterval (1h), RollupLookbackHours (3),
RollupRetentionDays (365), RollupThresholdHours (168, consumed by Task 5).
Validator adds bounds incl. the coherence rule RollupRetentionDays >=
RetentionDays so long-range trends have no data hole.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 11:58:50 -04:00
parent 09f67d1c65
commit 4f145fd62a
5 changed files with 710 additions and 20 deletions
@@ -35,4 +35,40 @@ public sealed class KpiHistoryOptions
/// <c>[2, 5000]</c>). At least two points are required to draw a line.
/// </summary>
public int DefaultMaxSeriesPoints { get; set; } = 200;
/// <summary>
/// How often the recorder folds the trailing raw <c>KpiSample</c> rows into the
/// hourly <c>KpiRollupHourly</c> table (default 1 hour). Must be strictly
/// positive. Each tick re-folds the trailing
/// <see cref="RollupLookbackHours"/> hours via an idempotent upsert, so a tick
/// missed during a singleton-failover handover self-heals on the next fold.
/// </summary>
public TimeSpan RollupInterval { get; set; } = TimeSpan.FromHours(1);
/// <summary>
/// How many trailing whole hours each rollup fold re-processes (default 3,
/// range <c>[1, 168]</c>). Folding a lookback window rather than just the last
/// hour lets an idempotent re-fold recover the one or more complete hours a
/// missed/failover tick skipped. Never includes the in-progress hour (the fold's
/// upper bound is the current hour start, exclusive).
/// </summary>
public int RollupLookbackHours { get; set; } = 3;
/// <summary>
/// Central retention window in days for the hourly rollup rows (default 365,
/// range <c>[1, 3650]</c>). Rollups outlive raw samples so long-range trend
/// charts have data after raw <c>KpiSample</c> rows are purged; the validator
/// therefore requires <c>RollupRetentionDays &gt;= <see cref="RetentionDays"/></c>
/// so a window never falls into a hole where raw is purged but no rollup was
/// written. Dropped by the rollup purge sweep.
/// </summary>
public int RollupRetentionDays { get; set; } = 365;
/// <summary>
/// Query-routing boundary in hours (default 168 = 7 days, minimum 24). Trend
/// queries whose window is <c>&lt;=</c> this width read raw <c>KpiSample</c> rows
/// (preserving intra-minute detail); wider windows read the pre-aggregated
/// hourly rollups. Consumed by the Central UI query service's range routing.
/// </summary>
public int RollupThresholdHours { get; set; } = 168;
}
@@ -28,6 +28,21 @@ public sealed class KpiHistoryOptionsValidator : OptionsValidatorBase<KpiHistory
/// <summary>Inclusive upper bound for <see cref="KpiHistoryOptions.DefaultMaxSeriesPoints"/>.</summary>
public const int MaxDefaultMaxSeriesPoints = 5000;
/// <summary>Inclusive lower bound for <see cref="KpiHistoryOptions.RollupLookbackHours"/>.</summary>
public const int MinRollupLookbackHours = 1;
/// <summary>Inclusive upper bound for <see cref="KpiHistoryOptions.RollupLookbackHours"/> (a week).</summary>
public const int MaxRollupLookbackHours = 168;
/// <summary>Inclusive lower bound for <see cref="KpiHistoryOptions.RollupRetentionDays"/>.</summary>
public const int MinRollupRetentionDays = 1;
/// <summary>Inclusive upper bound for <see cref="KpiHistoryOptions.RollupRetentionDays"/>.</summary>
public const int MaxRollupRetentionDays = 3650;
/// <summary>Inclusive lower bound for <see cref="KpiHistoryOptions.RollupThresholdHours"/> (one day).</summary>
public const int MinRollupThresholdHours = 24;
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, KpiHistoryOptions options)
{
@@ -54,5 +69,36 @@ public sealed class KpiHistoryOptionsValidator : OptionsValidatorBase<KpiHistory
|| options.DefaultMaxSeriesPoints > MaxDefaultMaxSeriesPoints),
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.DefaultMaxSeriesPoints)} ({options.DefaultMaxSeriesPoints}) " +
$"must be in [{MinDefaultMaxSeriesPoints}, {MaxDefaultMaxSeriesPoints}].");
builder.RequireThat(options.RollupInterval > TimeSpan.Zero,
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.RollupInterval)} ({options.RollupInterval}) " +
"must be > 0; it is the hourly rollup fold cadence.");
// Valid when RollupLookbackHours is within [Min, Max] inclusive.
builder.RequireThat(
!(options.RollupLookbackHours < MinRollupLookbackHours
|| options.RollupLookbackHours > MaxRollupLookbackHours),
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.RollupLookbackHours)} ({options.RollupLookbackHours}) " +
$"must be in [{MinRollupLookbackHours}, {MaxRollupLookbackHours}] hours.");
// Valid when RollupRetentionDays is within [Min, Max] inclusive.
builder.RequireThat(
!(options.RollupRetentionDays < MinRollupRetentionDays
|| options.RollupRetentionDays > MaxRollupRetentionDays),
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.RollupRetentionDays)} ({options.RollupRetentionDays}) " +
$"must be in [{MinRollupRetentionDays}, {MaxRollupRetentionDays}] days.");
// Coherence: rollups must be retained AT LEAST as long as raw samples, else a
// long-range chart hits a hole where raw is purged but the rollup was already
// dropped (or never written). Enforced independently of the range check above.
builder.RequireThat(
options.RollupRetentionDays >= options.RetentionDays,
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.RollupRetentionDays)} ({options.RollupRetentionDays}) " +
$"must be >= {nameof(KpiHistoryOptions.RetentionDays)} ({options.RetentionDays}) so rollups outlive " +
"raw samples and long-range trends have no data hole.");
builder.RequireThat(options.RollupThresholdHours >= MinRollupThresholdHours,
$"ScadaBridge:KpiHistory:{nameof(KpiHistoryOptions.RollupThresholdHours)} ({options.RollupThresholdHours}) " +
$"must be >= {MinRollupThresholdHours} hours; it is the raw-vs-rollup query-routing boundary.");
}
}
@@ -18,9 +18,17 @@ namespace ZB.MOM.WW.ScadaBridge.KpiHistory;
/// <item>Bulk-writes the combined batch through
/// <see cref="IKpiHistoryRepository.RecordSamplesAsync"/>.</item>
/// </list>
/// A separate daily timer (default 1 d) runs the retention purge, dropping rows
/// older than <see cref="KpiHistoryOptions.RetentionDays"/> via
/// <see cref="IKpiHistoryRepository.PurgeOlderThanAsync"/>.
/// A separate daily timer (default 1 d) runs the retention purge, dropping raw
/// sample rows older than <see cref="KpiHistoryOptions.RetentionDays"/> via
/// <see cref="IKpiHistoryRepository.PurgeOlderThanAsync"/> and hourly rollup rows
/// older than the longer <see cref="KpiHistoryOptions.RollupRetentionDays"/> via
/// <see cref="IKpiHistoryRepository.PurgeRollupsOlderThanAsync"/>. A third hourly
/// timer (default 1 h) folds the trailing
/// <see cref="KpiHistoryOptions.RollupLookbackHours"/> hours of raw samples into the
/// <c>KpiRollupHourly</c> table via
/// <see cref="IKpiHistoryRepository.FoldHourlyRollupsAsync"/> (idempotent upsert,
/// in-progress hour excluded), so long-range trend charts read a pre-thinned
/// series.
/// </summary>
/// <remarks>
/// <para>
@@ -57,6 +65,7 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
{
private const string SampleTimerKey = "kpi-sample";
private const string PurgeTimerKey = "kpi-purge";
private const string RollupTimerKey = "kpi-rollup";
private readonly IServiceProvider _serviceProvider;
private readonly KpiHistoryOptions _options;
@@ -82,6 +91,17 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
/// </summary>
private bool _sampleInFlight;
/// <summary>
/// In-flight guard for the hourly rollup fold. Set true at the start of a fold pass and
/// cleared when the pass's <see cref="RollupComplete"/> arrives. While true, further
/// <see cref="RollupTick"/>s are skipped so folds never overlap — the fold is a
/// group-by-and-upsert over a lookback window that can outlast the (default 1 h)
/// <see cref="KpiHistoryOptions.RollupInterval"/> on a slow/recovering store; without this
/// guard a subsequent tick would launch a second concurrent fold contending for the same
/// upsert key. Mirrors <see cref="_sampleInFlight"/>.
/// </summary>
private bool _rollupInFlight;
/// <summary>Akka timer scheduler, assigned by the actor system via <see cref="IWithTimers"/>.</summary>
public ITimerScheduler Timers { get; set; } = null!;
@@ -104,6 +124,8 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
Receive<SampleComplete>(_ => _sampleInFlight = false); // lower the in-flight guard (success or fault)
Receive<PurgeTick>(_ => HandlePurgeTick());
Receive<PurgeComplete>(_ => { }); // best-effort: no actor state to reset on completion
Receive<RollupTick>(_ => HandleRollupTick());
Receive<RollupComplete>(_ => _rollupInFlight = false); // lower the in-flight guard (success or fault)
}
/// <inheritdoc />
@@ -129,6 +151,16 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
PurgeTick.Instance,
initialDelay: PurgeTimerSchedule.InitialDelay(_options.PurgeInterval),
interval: _options.PurgeInterval);
// Fold the trailing hours into the hourly rollup table on a short initial delay
// (so a fresh active node catches up promptly after a failover), then settle into
// the periodic cadence. The fold re-processes a lookback window via an idempotent
// upsert, so a missed tick self-heals on the next fold.
Timers.StartPeriodicTimer(
RollupTimerKey,
RollupTick.Instance,
initialDelay: TimeSpan.FromSeconds(10),
interval: _options.RollupInterval);
}
/// <inheritdoc />
@@ -249,16 +281,20 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
}
/// <summary>
/// Handles a purge tick: computes the retention cut-off on the actor thread, then runs
/// the bulk delete off-thread and pipes a completion back to <see cref="Self"/>. Purges
/// are daily and idempotent, so no in-flight guard is needed.
/// Handles a purge tick: computes both retention cut-offs on the actor thread (raw samples
/// at <see cref="KpiHistoryOptions.RetentionDays"/>, hourly rollups at the longer
/// <see cref="KpiHistoryOptions.RollupRetentionDays"/>), then runs both bulk deletes
/// off-thread and pipes a completion back to <see cref="Self"/>. Purges are daily and
/// idempotent, so no in-flight guard is needed.
/// </summary>
private void HandlePurgeTick()
{
var before = DateTime.UtcNow - TimeSpan.FromDays(_options.RetentionDays);
var now = DateTime.UtcNow;
var sampleBefore = now - TimeSpan.FromDays(_options.RetentionDays);
var rollupBefore = now - TimeSpan.FromDays(_options.RollupRetentionDays);
var cancellationToken = _shutdownCts?.Token ?? CancellationToken.None;
RunPurgePass(before, cancellationToken).PipeTo(
RunPurgePass(sampleBefore, rollupBefore, cancellationToken).PipeTo(
Self,
success: deleted =>
{
@@ -266,7 +302,7 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
{
_logger.LogInformation(
"KPI history purge removed {DeletedCount} sample(s) older than {Cutoff:o}.",
deleted, before);
deleted, sampleBefore);
}
return PurgeComplete.Instance;
@@ -280,22 +316,53 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
/// <summary>
/// Runs a single purge sweep: opens a DI scope, resolves the repository, and bulk-deletes
/// rows captured before <paramref name="before"/>, returning the deleted count. The whole
/// body is wrapped so the returned task never faults — on failure the exception is logged
/// and 0 is returned, mirroring <see cref="RunSamplePass"/>'s best-effort contract.
/// expired raw samples (before <paramref name="sampleBefore"/>) and expired hourly rollups
/// (before <paramref name="rollupBefore"/>), returning the deleted raw-sample count. The two
/// deletes are isolated so a failure of one never skips the other, and the whole body is
/// wrapped so the returned task never faults — on failure the exception is logged and 0 is
/// returned, mirroring <see cref="RunSamplePass"/>'s best-effort contract.
/// </summary>
private async Task<int> RunPurgePass(DateTime before, CancellationToken cancellationToken)
private async Task<int> RunPurgePass(
DateTime sampleBefore, DateTime rollupBefore, CancellationToken cancellationToken)
{
try
{
await using var scope = _serviceProvider.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
return await repository.PurgeOlderThanAsync(before, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutdown interrupted the purge; the next active sweep retries. Not a failure.
return 0;
var deleted = 0;
// Raw-sample purge — isolated so a fault here does not skip the rollup purge below.
try
{
deleted = await repository.PurgeOlderThanAsync(sampleBefore, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutdown interrupted the purge; the next active sweep retries. Not a failure.
return deleted;
}
catch (Exception ex)
{
_logger.LogError(ex, "KPI history raw-sample purge failed unexpectedly.");
}
// Rollup purge — retained longer than raw; isolated so its failure does not affect
// the raw-purge outcome already reported.
try
{
await repository.PurgeRollupsOlderThanAsync(rollupBefore, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutdown interrupted the rollup purge; the next active sweep retries. Not a failure.
}
catch (Exception ex)
{
_logger.LogError(ex, "KPI history rollup purge failed unexpectedly.");
}
return deleted;
}
catch (Exception ex)
{
@@ -304,6 +371,71 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
}
}
/// <summary>
/// Handles a rollup tick. If a fold is already in flight the tick is skipped (logged at
/// debug) so folds never overlap; otherwise the in-flight guard is raised and the fold is
/// launched off-thread with a <see cref="RollupComplete"/> piped back to <see cref="Self"/>
/// to lower the guard on the actor thread. The fold window's upper bound is the current
/// hour start (exclusive) so the in-progress hour is never folded, and its lower bound is
/// <see cref="KpiHistoryOptions.RollupLookbackHours"/> earlier so a missed/failover tick
/// self-heals via the idempotent upsert.
/// </summary>
private void HandleRollupTick()
{
if (_rollupInFlight)
{
_logger.LogDebug("KPI rollup tick skipped — a rollup fold is already in flight.");
return;
}
_rollupInFlight = true;
var toHourUtc = TruncateToHour(DateTime.UtcNow);
var fromHourUtc = toHourUtc - TimeSpan.FromHours(_options.RollupLookbackHours);
var cancellationToken = _shutdownCts?.Token ?? CancellationToken.None;
RunRollupPass(fromHourUtc, toHourUtc, cancellationToken).PipeTo(
Self,
success: () => RollupComplete.Instance,
failure: ex =>
{
_logger.LogError(ex, "KPI rollup fold faulted unexpectedly.");
return RollupComplete.Instance;
});
}
/// <summary>
/// Runs a single rollup fold: 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 idempotent upsert. The whole body is wrapped so the
/// returned task never faults — best-effort observability must never disrupt anything.
/// </summary>
private async Task RunRollupPass(
DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken)
{
try
{
await using var scope = _serviceProvider.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IKpiHistoryRepository>();
await repository.FoldHourlyRollupsAsync(fromHourUtc, toHourUtc, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Shutdown interrupted the fold; the next active node re-folds the lookback window. Not a failure.
}
catch (Exception ex)
{
_logger.LogError(ex, "KPI rollup fold failed unexpectedly.");
}
}
/// <summary>
/// Truncates a UTC instant to the start of its hour (minutes/seconds/ticks dropped),
/// preserving <see cref="DateTimeKind.Utc"/>. Used to derive the fold window's whole-hour
/// boundaries so the in-progress hour is excluded.
/// </summary>
private static DateTime TruncateToHour(DateTime utc) =>
new(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc);
/// <summary>Self-tick triggering a sampling pass across all registered sources.</summary>
internal sealed class SampleTick
{
@@ -334,4 +466,21 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers
public static readonly PurgeComplete Instance = new();
private PurgeComplete() { }
}
/// <summary>Self-tick triggering an hourly rollup fold over the trailing lookback window.</summary>
internal sealed class RollupTick
{
public static readonly RollupTick Instance = new();
private RollupTick() { }
}
/// <summary>
/// Piped-back completion of a rollup fold; lets the fold run off the actor thread and
/// lowers the <c>_rollupInFlight</c> guard on the actor thread (fires on success and fault).
/// </summary>
internal sealed class RollupComplete
{
public static readonly RollupComplete Instance = new();
private RollupComplete() { }
}
}
@@ -56,7 +56,10 @@ public class KpiHistoryOptionsValidatorTests
[InlineData(3650)] // max
public void Validate_RetentionDays_InRange_Passes(int value)
{
var opts = new KpiHistoryOptions { RetentionDays = value };
// Keep RollupRetentionDays coherent (>= RetentionDays) so this test isolates the
// RetentionDays range rule and doesn't trip the rollup-retention coherence rule when
// RetentionDays is set above the default RollupRetentionDays (365).
var opts = new KpiHistoryOptions { RetentionDays = value, RollupRetentionDays = value };
Assert.True(NewValidator().Validate(null, opts).Succeeded);
}
@@ -132,6 +135,137 @@ public class KpiHistoryOptionsValidatorTests
f => f.Contains(nameof(KpiHistoryOptions.DefaultMaxSeriesPoints), StringComparison.Ordinal));
}
// ---------------------------------------------------------------------
// RollupInterval > TimeSpan.Zero
// ---------------------------------------------------------------------
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Validate_RollupInterval_NotPositive_Fails(int hours)
{
var opts = new KpiHistoryOptions { RollupInterval = TimeSpan.FromHours(hours) };
var result = NewValidator().Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains(nameof(KpiHistoryOptions.RollupInterval), StringComparison.Ordinal));
}
// ---------------------------------------------------------------------
// RollupLookbackHours in [1, 168]
// ---------------------------------------------------------------------
[Theory]
[InlineData(1)] // min
[InlineData(3)] // default
[InlineData(168)] // max
public void Validate_RollupLookbackHours_InRange_Passes(int value)
{
var opts = new KpiHistoryOptions { RollupLookbackHours = value };
Assert.True(NewValidator().Validate(null, opts).Succeeded);
}
[Theory]
[InlineData(0)]
[InlineData(169)]
[InlineData(-1)]
public void Validate_RollupLookbackHours_OutOfRange_Fails(int value)
{
var opts = new KpiHistoryOptions { RollupLookbackHours = value };
var result = NewValidator().Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains(nameof(KpiHistoryOptions.RollupLookbackHours), StringComparison.Ordinal));
}
// ---------------------------------------------------------------------
// RollupRetentionDays in [1, 3650] AND >= RetentionDays
// ---------------------------------------------------------------------
[Theory]
[InlineData(90)] // == RetentionDays default
[InlineData(365)] // default
[InlineData(3650)] // max
public void Validate_RollupRetentionDays_InRange_Passes(int value)
{
var opts = new KpiHistoryOptions { RollupRetentionDays = value };
Assert.True(NewValidator().Validate(null, opts).Succeeded);
}
[Theory]
[InlineData(0)]
[InlineData(3651)]
[InlineData(-1)]
public void Validate_RollupRetentionDays_OutOfRange_Fails(int value)
{
var opts = new KpiHistoryOptions { RollupRetentionDays = value };
var result = NewValidator().Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains(nameof(KpiHistoryOptions.RollupRetentionDays), StringComparison.Ordinal));
}
[Fact]
public void Validate_RollupRetentionDays_LessThanRetentionDays_Fails()
{
// Coherence rule: rollups must outlive raw samples, else long-range trends hit a hole.
var opts = new KpiHistoryOptions
{
RetentionDays = 90,
RollupRetentionDays = 30, // in [1,3650] but < RetentionDays
};
var result = NewValidator().Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains(nameof(KpiHistoryOptions.RollupRetentionDays), StringComparison.Ordinal)
&& f.Contains(nameof(KpiHistoryOptions.RetentionDays), StringComparison.Ordinal));
}
[Fact]
public void Validate_RollupRetentionDays_EqualToRetentionDays_Passes()
{
// Boundary of the coherence rule (>=): equal retention is allowed.
var opts = new KpiHistoryOptions { RetentionDays = 120, RollupRetentionDays = 120 };
Assert.True(NewValidator().Validate(null, opts).Succeeded);
}
// ---------------------------------------------------------------------
// RollupThresholdHours >= 24
// ---------------------------------------------------------------------
[Theory]
[InlineData(24)] // min
[InlineData(168)] // default
[InlineData(2160)] // 90 d
public void Validate_RollupThresholdHours_AtOrAboveMin_Passes(int value)
{
var opts = new KpiHistoryOptions { RollupThresholdHours = value };
Assert.True(NewValidator().Validate(null, opts).Succeeded);
}
[Theory]
[InlineData(23)]
[InlineData(0)]
[InlineData(-1)]
public void Validate_RollupThresholdHours_BelowMin_Fails(int value)
{
var opts = new KpiHistoryOptions { RollupThresholdHours = value };
var result = NewValidator().Validate(null, opts);
Assert.False(result.Succeeded);
Assert.Contains(
result.Failures!,
f => f.Contains(nameof(KpiHistoryOptions.RollupThresholdHours), StringComparison.Ordinal));
}
[Fact]
public void Validate_AllRulesViolated_AccumulatesEveryFailure()
{
@@ -107,6 +107,10 @@ public class KpiHistoryRecorderActorTests : TestKit
private readonly object _gate = new();
private readonly List<KpiSample> _recorded = new();
private DateTime? _purgeCutoff;
private DateTime? _rollupPurgeCutoff;
private DateTime? _foldFromHourUtc;
private DateTime? _foldToHourUtc;
private int _foldCount;
public IReadOnlyList<KpiSample> Recorded
{
@@ -120,6 +124,30 @@ public class KpiHistoryRecorderActorTests : TestKit
get { lock (_gate) { return _purgeCutoff; } }
}
/// <summary>Cut-off handed to <see cref="PurgeRollupsOlderThanAsync"/> (rollup retention).</summary>
public DateTime? RollupPurgeCutoff
{
get { lock (_gate) { return _rollupPurgeCutoff; } }
}
/// <summary>Inclusive lower bound handed to <see cref="FoldHourlyRollupsAsync"/>.</summary>
public DateTime? FoldFromHourUtc
{
get { lock (_gate) { return _foldFromHourUtc; } }
}
/// <summary>Exclusive upper bound handed to <see cref="FoldHourlyRollupsAsync"/>.</summary>
public DateTime? FoldToHourUtc
{
get { lock (_gate) { return _foldToHourUtc; } }
}
/// <summary>Number of times <see cref="FoldHourlyRollupsAsync"/> was entered.</summary>
public int FoldCount
{
get { lock (_gate) { return _foldCount; } }
}
public Task RecordSamplesAsync(
IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default)
{
@@ -140,6 +168,29 @@ public class KpiHistoryRecorderActorTests : TestKit
lock (_gate) { _purgeCutoff = before; }
return Task.FromResult(0);
}
public Task FoldHourlyRollupsAsync(
DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default)
{
lock (_gate)
{
_foldFromHourUtc = fromHourUtc;
_foldToHourUtc = toHourUtc;
_foldCount++;
}
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)
{
lock (_gate) { _rollupPurgeCutoff = before; }
return Task.CompletedTask;
}
}
/// <summary>
@@ -174,6 +225,121 @@ public class KpiHistoryRecorderActorTests : TestKit
public Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) =>
Task.FromResult(0);
public Task FoldHourlyRollupsAsync(
DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default) =>
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;
}
/// <summary>
/// Repository fake whose <see cref="FoldHourlyRollupsAsync"/> blocks on a manual-reset gate
/// until the test releases it, holding a rollup fold "in flight" so an overlapping-tick
/// scenario can be driven deterministically. Counts how many times the fold was entered.
/// </summary>
private sealed class GatedFoldRepository : IKpiHistoryRepository
{
private readonly ManualResetEventSlim _release = new(initialState: false);
private int _foldCount;
/// <summary>Number of times <see cref="FoldHourlyRollupsAsync"/> has been entered.</summary>
public int FoldCount => Volatile.Read(ref _foldCount);
/// <summary>Releases all blocked folds so the gated passes can complete.</summary>
public void Release() => _release.Set();
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)
{
Interlocked.Increment(ref _foldCount);
// Block on a threadpool thread (PipeTo runs the pass off the actor thread), holding
// the fold in flight until the test opens the gate.
return Task.Run(() => _release.Wait(cancellationToken), cancellationToken);
}
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;
}
/// <summary>
/// Repository fake whose <see cref="FoldHourlyRollupsAsync"/> throws on the first call and
/// succeeds (recording the window) on every subsequent call — drives a faulted first fold
/// followed by a healthy second fold on the same actor instance.
/// </summary>
private sealed class ThrowOnceFoldRepository : IKpiHistoryRepository
{
private readonly object _gate = new();
private int _foldCount;
private DateTime? _lastFoldToHourUtc;
/// <summary>Number of times <see cref="FoldHourlyRollupsAsync"/> has been entered (incl. the throw).</summary>
public int FoldCount
{
get { lock (_gate) { return _foldCount; } }
}
/// <summary>Exclusive upper bound of the most recent successful fold.</summary>
public DateTime? LastFoldToHourUtc
{
get { lock (_gate) { return _lastFoldToHourUtc; } }
}
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)
{
lock (_gate)
{
_foldCount++;
if (_foldCount == 1)
throw new InvalidOperationException("simulated first-fold failure");
_lastFoldToHourUtc = toHourUtc;
}
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;
}
private IServiceProvider BuildServiceProvider(
@@ -337,4 +503,163 @@ public class KpiHistoryRecorderActorTests : TestKit
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
// =====================================================================
// Hourly rollup fold (Task 4)
// =====================================================================
[Fact]
public void RollupTick_FoldsTrailingLookback_WithInProgressHourExcluded()
{
// The fold's upper bound must be the CURRENT hour start (exclusive) so the in-progress
// hour is never folded, and its lower bound must be RollupLookbackHours earlier so a
// missed/failover tick self-heals via the idempotent upsert.
var repository = new RecordingRepository();
var sp = BuildServiceProvider(repository, new HealthySource());
const int lookbackHours = 3;
var actor = CreateActor(sp, new KpiHistoryOptions
{
SampleInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromHours(1),
RollupInterval = TimeSpan.FromHours(1),
RollupLookbackHours = lookbackHours,
});
actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
AwaitAssert(
() =>
{
Assert.NotNull(repository.FoldToHourUtc);
Assert.NotNull(repository.FoldFromHourUtc);
var toHour = repository.FoldToHourUtc!.Value;
var fromHour = repository.FoldFromHourUtc!.Value;
// toHourUtc 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,
$"fold toHour {toHour:o} should be the current hour start {expectedToHour:o}");
// fromHour is exactly lookbackHours before toHour.
Assert.Equal(toHour - TimeSpan.FromHours(lookbackHours), fromHour);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
[Fact]
public void PurgeTick_PurgesBothRawSamplesAndRollups_WithTheirOwnCutoffs()
{
// The daily purge must drop raw samples at RetentionDays AND rollups at the longer
// RollupRetentionDays — each with its own cut-off, both best-effort.
var repository = new RecordingRepository();
var sp = BuildServiceProvider(repository, new HealthySource());
const int retentionDays = 90;
const int rollupRetentionDays = 365;
var actor = CreateActor(sp, new KpiHistoryOptions
{
SampleInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromHours(1),
RetentionDays = retentionDays,
RollupRetentionDays = rollupRetentionDays,
});
actor.Tell(KpiHistoryRecorderActor.PurgeTick.Instance);
AwaitAssert(
() =>
{
Assert.NotNull(repository.PurgeCutoff);
Assert.NotNull(repository.RollupPurgeCutoff);
var expectedRaw = DateTime.UtcNow - TimeSpan.FromDays(retentionDays);
var expectedRollup = DateTime.UtcNow - TimeSpan.FromDays(rollupRetentionDays);
Assert.True(
Math.Abs((repository.PurgeCutoff!.Value - expectedRaw).TotalMinutes) < 1.0,
$"raw purge cutoff {repository.PurgeCutoff:o} should be within 1 minute of {expectedRaw:o}");
Assert.True(
Math.Abs((repository.RollupPurgeCutoff!.Value - expectedRollup).TotalMinutes) < 1.0,
$"rollup purge cutoff {repository.RollupPurgeCutoff:o} should be within 1 minute of {expectedRollup:o}");
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
[Fact]
public void OverlappingRollupTick_WhileFirstFoldInFlight_DoesNotStartSecondFold()
{
// The _rollupInFlight guard must coalesce a tick that arrives while a prior fold is
// still running. With a gated fold held open, a second RollupTick must NOT spawn a
// second fold — so FoldHourlyRollupsAsync is entered exactly once until the gate opens.
var repository = new GatedFoldRepository();
var sp = BuildServiceProvider(repository, new HealthySource());
var actor = CreateActor(sp);
// First tick: raises the guard, enters the (gated, blocking) fold — held in flight.
actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
AwaitAssert(
() => Assert.Equal(1, repository.FoldCount),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// Second tick while the first fold is still 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 first fold completes and the guard is lowered; a fresh tick
// must now run a new fold (guard correctly reset, not stuck). Re-send the tick on each
// poll so we don't race the asynchronous RollupComplete 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));
}
[Fact]
public void FaultedRollupFold_DoesNotCrashActor_AndSubsequentTickStillFolds()
{
// A fold that throws must be caught (best-effort) — it must neither crash the singleton
// nor stop future rollup ticks. ThrowOnceFoldRepository throws on the first fold and
// succeeds afterward, so a healthy second fold on the SAME actor proves the loop lives.
var repository = new ThrowOnceFoldRepository();
var sp = BuildServiceProvider(repository, new HealthySource());
var actor = CreateActor(sp);
// First tick: fold throws — caught, actor lives, no successful fold recorded.
actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
AwaitAssert(
() => Assert.Null(repository.LastFoldToHourUtc),
duration: TimeSpan.FromSeconds(2),
interval: TimeSpan.FromMilliseconds(50));
// Second tick to the SAME actor: fold now succeeds. Re-send on each poll so we don't
// race the asynchronous RollupComplete that lowers the guard after the faulted fold.
AwaitAssert(
() =>
{
actor.Tell(KpiHistoryRecorderActor.RollupTick.Instance);
Assert.NotNull(repository.LastFoldToHourUtc);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
}