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