From 4f145fd62a6dbead936851421a4be8fed148bf45 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 10 Jul 2026 11:58:50 -0400 Subject: [PATCH] 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 --- .../KpiHistoryOptions.cs | 36 ++ .../KpiHistoryOptionsValidator.cs | 46 +++ .../KpiHistoryRecorderActor.cs | 187 +++++++++- .../KpiHistoryOptionsValidatorTests.cs | 136 +++++++- .../KpiHistoryRecorderActorTests.cs | 325 ++++++++++++++++++ 5 files changed, 710 insertions(+), 20 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptions.cs b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptions.cs index 15ff01f2..67d55056 100644 --- a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptions.cs @@ -35,4 +35,40 @@ public sealed class KpiHistoryOptions /// [2, 5000]). At least two points are required to draw a line. /// public int DefaultMaxSeriesPoints { get; set; } = 200; + + /// + /// How often the recorder folds the trailing raw KpiSample rows into the + /// hourly KpiRollupHourly table (default 1 hour). Must be strictly + /// positive. Each tick re-folds the trailing + /// hours via an idempotent upsert, so a tick + /// missed during a singleton-failover handover self-heals on the next fold. + /// + public TimeSpan RollupInterval { get; set; } = TimeSpan.FromHours(1); + + /// + /// How many trailing whole hours each rollup fold re-processes (default 3, + /// range [1, 168]). 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). + /// + public int RollupLookbackHours { get; set; } = 3; + + /// + /// Central retention window in days for the hourly rollup rows (default 365, + /// range [1, 3650]). Rollups outlive raw samples so long-range trend + /// charts have data after raw KpiSample rows are purged; the validator + /// therefore requires RollupRetentionDays >= + /// so a window never falls into a hole where raw is purged but no rollup was + /// written. Dropped by the rollup purge sweep. + /// + public int RollupRetentionDays { get; set; } = 365; + + /// + /// Query-routing boundary in hours (default 168 = 7 days, minimum 24). Trend + /// queries whose window is <= this width read raw KpiSample rows + /// (preserving intra-minute detail); wider windows read the pre-aggregated + /// hourly rollups. Consumed by the Central UI query service's range routing. + /// + public int RollupThresholdHours { get; set; } = 168; } diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptionsValidator.cs index f8e48214..7d78758c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryOptionsValidator.cs @@ -28,6 +28,21 @@ public sealed class KpiHistoryOptionsValidator : OptionsValidatorBaseInclusive upper bound for . public const int MaxDefaultMaxSeriesPoints = 5000; + /// Inclusive lower bound for . + public const int MinRollupLookbackHours = 1; + + /// Inclusive upper bound for (a week). + public const int MaxRollupLookbackHours = 168; + + /// Inclusive lower bound for . + public const int MinRollupRetentionDays = 1; + + /// Inclusive upper bound for . + public const int MaxRollupRetentionDays = 3650; + + /// Inclusive lower bound for (one day). + public const int MinRollupThresholdHours = 24; + /// protected override void Validate(ValidationBuilder builder, KpiHistoryOptions options) { @@ -54,5 +69,36 @@ public sealed class KpiHistoryOptionsValidator : OptionsValidatorBase 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."); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs index 18a887e9..7a2cd7bd 100644 --- a/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.KpiHistory/KpiHistoryRecorderActor.cs @@ -18,9 +18,17 @@ namespace ZB.MOM.WW.ScadaBridge.KpiHistory; /// Bulk-writes the combined batch through /// . /// -/// A separate daily timer (default 1 d) runs the retention purge, dropping rows -/// older than via -/// . +/// A separate daily timer (default 1 d) runs the retention purge, dropping raw +/// sample rows older than via +/// and hourly rollup rows +/// older than the longer via +/// . A third hourly +/// timer (default 1 h) folds the trailing +/// hours of raw samples into the +/// KpiRollupHourly table via +/// (idempotent upsert, +/// in-progress hour excluded), so long-range trend charts read a pre-thinned +/// series. /// /// /// @@ -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 /// private bool _sampleInFlight; + /// + /// In-flight guard for the hourly rollup fold. Set true at the start of a fold pass and + /// cleared when the pass's arrives. While true, further + /// 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) + /// on a slow/recovering store; without this + /// guard a subsequent tick would launch a second concurrent fold contending for the same + /// upsert key. Mirrors . + /// + private bool _rollupInFlight; + /// Akka timer scheduler, assigned by the actor system via . public ITimerScheduler Timers { get; set; } = null!; @@ -104,6 +124,8 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers Receive(_ => _sampleInFlight = false); // lower the in-flight guard (success or fault) Receive(_ => HandlePurgeTick()); Receive(_ => { }); // best-effort: no actor state to reset on completion + Receive(_ => HandleRollupTick()); + Receive(_ => _rollupInFlight = false); // lower the in-flight guard (success or fault) } /// @@ -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); } /// @@ -249,16 +281,20 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers } /// - /// 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 . 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 , hourly rollups at the longer + /// ), then runs both bulk deletes + /// off-thread and pipes a completion back to . Purges are daily and + /// idempotent, so no in-flight guard is needed. /// 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 /// /// Runs a single purge sweep: opens a DI scope, resolves the repository, and bulk-deletes - /// rows captured 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 's best-effort contract. + /// expired raw samples (before ) and expired hourly rollups + /// (before ), 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 's best-effort contract. /// - private async Task RunPurgePass(DateTime before, CancellationToken cancellationToken) + private async Task RunPurgePass( + DateTime sampleBefore, DateTime rollupBefore, CancellationToken cancellationToken) { try { await using var scope = _serviceProvider.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); - 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 } } + /// + /// 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 piped back to + /// 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 + /// earlier so a missed/failover tick + /// self-heals via the idempotent upsert. + /// + 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; + }); + } + + /// + /// Runs a single rollup fold: opens a DI scope, resolves the repository, and folds the + /// complete hours in [, ) + /// 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. + /// + private async Task RunRollupPass( + DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken) + { + try + { + await using var scope = _serviceProvider.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + 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."); + } + } + + /// + /// Truncates a UTC instant to the start of its hour (minutes/seconds/ticks dropped), + /// preserving . Used to derive the fold window's whole-hour + /// boundaries so the in-progress hour is excluded. + /// + private static DateTime TruncateToHour(DateTime utc) => + new(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc); + /// Self-tick triggering a sampling pass across all registered sources. internal sealed class SampleTick { @@ -334,4 +466,21 @@ public class KpiHistoryRecorderActor : ReceiveActor, IWithTimers public static readonly PurgeComplete Instance = new(); private PurgeComplete() { } } + + /// Self-tick triggering an hourly rollup fold over the trailing lookback window. + internal sealed class RollupTick + { + public static readonly RollupTick Instance = new(); + private RollupTick() { } + } + + /// + /// Piped-back completion of a rollup fold; lets the fold run off the actor thread and + /// lowers the _rollupInFlight guard on the actor thread (fires on success and fault). + /// + internal sealed class RollupComplete + { + public static readonly RollupComplete Instance = new(); + private RollupComplete() { } + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs index e3969029..b1585220 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryOptionsValidatorTests.cs @@ -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() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs index 27f4bf60..f17e2125 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs @@ -107,6 +107,10 @@ public class KpiHistoryRecorderActorTests : TestKit private readonly object _gate = new(); private readonly List _recorded = new(); private DateTime? _purgeCutoff; + private DateTime? _rollupPurgeCutoff; + private DateTime? _foldFromHourUtc; + private DateTime? _foldToHourUtc; + private int _foldCount; public IReadOnlyList Recorded { @@ -120,6 +124,30 @@ public class KpiHistoryRecorderActorTests : TestKit get { lock (_gate) { return _purgeCutoff; } } } + /// Cut-off handed to (rollup retention). + public DateTime? RollupPurgeCutoff + { + get { lock (_gate) { return _rollupPurgeCutoff; } } + } + + /// Inclusive lower bound handed to . + public DateTime? FoldFromHourUtc + { + get { lock (_gate) { return _foldFromHourUtc; } } + } + + /// Exclusive upper bound handed to . + public DateTime? FoldToHourUtc + { + get { lock (_gate) { return _foldToHourUtc; } } + } + + /// Number of times was entered. + public int FoldCount + { + get { lock (_gate) { return _foldCount; } } + } + public Task RecordSamplesAsync( IReadOnlyCollection 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> GetHourlySeriesAsync( + string source, string metric, string scope, string? scopeKey, + DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); + + public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) + { + lock (_gate) { _rollupPurgeCutoff = before; } + return Task.CompletedTask; + } } /// @@ -174,6 +225,121 @@ public class KpiHistoryRecorderActorTests : TestKit public Task PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => Task.FromResult(0); + + public Task FoldHourlyRollupsAsync( + DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task> GetHourlySeriesAsync( + string source, string metric, string scope, string? scopeKey, + DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); + + public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } + + /// + /// Repository fake whose 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. + /// + private sealed class GatedFoldRepository : IKpiHistoryRepository + { + private readonly ManualResetEventSlim _release = new(initialState: false); + private int _foldCount; + + /// Number of times has been entered. + public int FoldCount => Volatile.Read(ref _foldCount); + + /// Releases all blocked folds so the gated passes can complete. + public void Release() => _release.Set(); + + public Task RecordSamplesAsync( + IReadOnlyCollection samples, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task> GetRawSeriesAsync( + string source, string metric, string scope, string? scopeKey, + DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); + + public Task 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> GetHourlySeriesAsync( + string source, string metric, string scope, string? scopeKey, + DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); + + public Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } + + /// + /// Repository fake whose 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. + /// + private sealed class ThrowOnceFoldRepository : IKpiHistoryRepository + { + private readonly object _gate = new(); + private int _foldCount; + private DateTime? _lastFoldToHourUtc; + + /// Number of times has been entered (incl. the throw). + public int FoldCount + { + get { lock (_gate) { return _foldCount; } } + } + + /// Exclusive upper bound of the most recent successful fold. + public DateTime? LastFoldToHourUtc + { + get { lock (_gate) { return _lastFoldToHourUtc; } } + } + + public Task RecordSamplesAsync( + IReadOnlyCollection samples, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task> GetRawSeriesAsync( + string source, string metric, string scope, string? scopeKey, + DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); + + public Task 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> GetHourlySeriesAsync( + string source, string metric, string scope, string? scopeKey, + DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); + + 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)); + } }