Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.KpiHistory.Tests/KpiHistoryRecorderActorTests.cs
T

1091 lines
49 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Kpi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
namespace ZB.MOM.WW.ScadaBridge.KpiHistory.Tests;
/// <summary>
/// K4 tests for <see cref="KpiHistoryRecorderActor"/>. The actor's internal
/// <c>SampleTick</c>/<c>PurgeTick</c> messages are exposed to this assembly via
/// <c>InternalsVisibleTo</c> so a tick can be driven deterministically without
/// racing the periodic timer. Hand-rolled fakes (no mocking lib in this test
/// project) record what the recorder hands to the repository.
/// </summary>
public class KpiHistoryRecorderActorTests : TestKit
{
/// <summary>
/// A healthy sample source that returns a fixed set of samples, stamping each with the
/// <c>capturedAtUtc</c> the recorder supplies.
/// </summary>
private sealed class HealthySource : IKpiSampleSource
{
public string Source => KpiSources.NotificationOutbox;
public Task<IReadOnlyList<KpiSample>> CollectAsync(
DateTime capturedAtUtc, CancellationToken cancellationToken = default)
{
IReadOnlyList<KpiSample> samples = new List<KpiSample>
{
new()
{
Source = Source,
Metric = "QueueDepth",
Scope = KpiScopes.Global,
ScopeKey = null,
Value = 7,
CapturedAtUtc = capturedAtUtc,
},
new()
{
Source = Source,
Metric = "ParkedCount",
Scope = KpiScopes.Global,
ScopeKey = null,
Value = 2,
CapturedAtUtc = capturedAtUtc,
},
};
return Task.FromResult(samples);
}
}
/// <summary>A source whose <see cref="CollectAsync"/> always throws — must not abort the pass.</summary>
private sealed class ThrowingSource : IKpiSampleSource
{
public string Source => KpiSources.SiteCallAudit;
public Task<IReadOnlyList<KpiSample>> CollectAsync(
DateTime capturedAtUtc, CancellationToken cancellationToken = default) =>
throw new InvalidOperationException("simulated source failure");
}
/// <summary>
/// A source that throws on the first <see cref="CollectAsync"/> call and returns a
/// healthy sample on every subsequent call. Used to drive a faulted first tick followed
/// by a healthy second tick on the <em>same</em> actor instance.
/// </summary>
private sealed class ThrowOnceSource : IKpiSampleSource
{
private int _callCount;
public string Source => KpiSources.SiteCallAudit;
public Task<IReadOnlyList<KpiSample>> CollectAsync(
DateTime capturedAtUtc, CancellationToken cancellationToken = default)
{
if (Interlocked.Increment(ref _callCount) == 1)
throw new InvalidOperationException("simulated first-call source failure");
IReadOnlyList<KpiSample> samples = new[]
{
new KpiSample
{
Source = Source,
Metric = "RecoveredSample",
Scope = KpiScopes.Global,
ScopeKey = null,
Value = 1,
CapturedAtUtc = capturedAtUtc,
},
};
return Task.FromResult(samples);
}
}
/// <summary>
/// Recording repository fake. Captures the samples handed to
/// <see cref="RecordSamplesAsync"/> and the cut-off handed to
/// <see cref="PurgeOlderThanAsync"/>.
/// </summary>
private sealed class RecordingRepository : IKpiHistoryRepository
{
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;
private readonly List<(DateTime FromHourUtc, DateTime ToHourUtc)> _foldWindows = new();
private DateTime? _latestRollupHour;
private int _latestRollupHourQueryCount;
public IReadOnlyList<KpiSample> Recorded
{
get { lock (_gate) { return _recorded.ToArray(); } }
}
// PurgeOlderThanAsync runs on a threadpool thread; guard the field with
// the same _gate lock used by _recorded so test-thread reads are race-free.
public DateTime? PurgeCutoff
{
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; } }
}
/// <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)
{
lock (_gate)
{
_recorded.AddRange(samples);
}
return 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)
{
lock (_gate) { _purgeCutoff = before; }
return Task.FromResult(0);
}
public Task FoldHourlyRollupsAsync(
DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default)
{
lock (_gate)
{
_foldFromHourUtc = fromHourUtc;
_foldToHourUtc = toHourUtc;
_foldWindows.Add((fromHourUtc, 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>Watermark the fake returns from <see cref="GetLatestRollupHourAsync"/> (settable).</summary>
public DateTime? LatestRollupHour
{
get { lock (_gate) { return _latestRollupHour; } }
set { lock (_gate) { _latestRollupHour = value; } }
}
/// <summary>Number of times the backfill plan pass queried the rollup watermark.</summary>
public int LatestRollupHourQueryCount
{
get { lock (_gate) { return _latestRollupHourQueryCount; } }
}
public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default)
{
lock (_gate)
{
_latestRollupHourQueryCount++;
return Task.FromResult(_latestRollupHour);
}
}
}
/// <summary>
/// Repository fake whose <see cref="RecordSamplesAsync"/> blocks on a manual-reset gate
/// until the test releases it, holding a sample pass "in flight" so an overlapping-tick
/// scenario can be driven deterministically. Counts how many times the write was entered.
/// </summary>
private sealed class GatedRepository : IKpiHistoryRepository
{
private readonly ManualResetEventSlim _release = new(initialState: false);
private int _writeCount;
/// <summary>Number of times <see cref="RecordSamplesAsync"/> has been entered.</summary>
public int WriteCount => Volatile.Read(ref _writeCount);
/// <summary>Releases all blocked writes so the gated passes can complete.</summary>
public void Release() => _release.Set();
public Task RecordSamplesAsync(
IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _writeCount);
// Block on a threadpool thread (PipeTo runs the pass off the actor thread), holding
// the pass in flight until the test opens the gate.
return Task.Run(() => _release.Wait(cancellationToken), cancellationToken);
}
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) =>
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);
}
/// <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;
public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<DateTime?>(null);
}
/// <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;
public Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default) =>
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)
{
var services = new ServiceCollection();
// Mirror production: sources + repository are scoped, so the recorder opens a fresh
// scope per tick and resolves there.
foreach (var source in sources)
{
var captured = source;
services.AddScoped(_ => captured);
}
services.AddScoped(_ => repository);
return services.BuildServiceProvider();
}
/// <summary>
/// Creates the recorder with both timers set to a long interval so neither periodic timer
/// fires during a test — ticks are sent manually instead.
/// </summary>
private IActorRef CreateActor(IServiceProvider serviceProvider, KpiHistoryOptions? options = null)
{
return Sys.ActorOf(Props.Create(() => new KpiHistoryRecorderActor(
serviceProvider,
options ?? new KpiHistoryOptions
{
SampleInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromHours(1),
},
NullLogger<KpiHistoryRecorderActor>.Instance)));
}
[Fact]
public void SampleTick_WritesHealthySourceSamples_AndThrowingSourceDoesNotAbortTick()
{
var repository = new RecordingRepository();
// Order the throwing source FIRST so the test also proves a throw early in the
// enumeration does not suppress a later healthy source.
var sp = BuildServiceProvider(repository, new ThrowingSource(), new HealthySource());
var actor = CreateActor(sp);
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
AwaitAssert(
() =>
{
// The healthy source's two samples were written despite the throwing source.
Assert.Equal(2, repository.Recorded.Count);
Assert.Contains(repository.Recorded, s => s.Metric == "QueueDepth" && s.Value == 7);
Assert.Contains(repository.Recorded, s => s.Metric == "ParkedCount" && s.Value == 2);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
[Fact]
public void PurgeTick_CallsPurgeWithCutoff_AtUtcNowMinusRetentionDays()
{
var repository = new RecordingRepository();
var sp = BuildServiceProvider(repository, new HealthySource());
const int retentionDays = 30;
var actor = CreateActor(sp, new KpiHistoryOptions
{
SampleInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromHours(1),
RetentionDays = retentionDays,
});
actor.Tell(KpiHistoryRecorderActor.PurgeTick.Instance);
AwaitAssert(
() =>
{
Assert.NotNull(repository.PurgeCutoff);
var expected = DateTime.UtcNow - TimeSpan.FromDays(retentionDays);
Assert.True(
Math.Abs((repository.PurgeCutoff!.Value - expected).TotalMinutes) < 1.0,
$"purge cutoff {repository.PurgeCutoff:o} should be within 1 minute of {expected:o}");
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
[Fact]
public void FaultedTick_DoesNotCrashActor_AndSubsequentTickStillRuns()
{
// ThrowOnceSource throws on the first CollectAsync call and returns a healthy
// sample on every subsequent call. This lets us send two ticks to the SAME
// actor instance and verify that:
// • The first tick (faulted source) records nothing but does not crash the actor.
// • The second tick reaches the same actor and records the recovered sample,
// proving the singleton's message loop is still alive after a faulted pass.
var repository = new RecordingRepository();
var sp = BuildServiceProvider(repository, new ThrowOnceSource());
var actor = CreateActor(sp);
// First tick: source throws on first call — caught per-source, nothing written, actor lives.
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
AwaitAssert(
() => Assert.Empty(repository.Recorded),
duration: TimeSpan.FromSeconds(2),
interval: TimeSpan.FromMilliseconds(50));
// Second tick to the SAME actor: source now returns a healthy sample. Re-send the tick
// on each poll so we don't race the (asynchronous) SampleComplete that lowers the
// in-flight guard after the faulted first pass — a tick that lands before the guard
// clears is harmlessly skipped, and the next poll's tick runs. The recovered sample
// being recorded proves the actor's message loop is still alive after a faulted pass.
// (>= 1 rather than exactly-1: a later re-sent tick could run an extra recovering pass
// once the guard clears, which is not what this test pins — KH-001's one-pass-per-tick
// guard is pinned by OverlappingTick_WhileFirstPassInFlight_DoesNotStartSecondPass.)
AwaitAssert(
() =>
{
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
Assert.NotEmpty(repository.Recorded);
},
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
}
[Fact]
public void OverlappingTick_WhileFirstPassInFlight_DoesNotStartSecondPass()
{
// KpiHistory-001 regression: the in-flight guard must coalesce a tick that arrives
// while a prior sample pass is still awaiting its DB write. With a gated repository
// holding the first write open, a second SampleTick must NOT spawn a second pass —
// so RecordSamplesAsync is entered exactly once until the gate is released.
var repository = new GatedRepository();
var sp = BuildServiceProvider(repository, new HealthySource());
var actor = CreateActor(sp);
// First tick: raises the guard, enters the (gated, blocking) write — held in flight.
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
AwaitAssert(
() => Assert.Equal(1, repository.WriteCount),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// Second tick while the first pass is still in flight: must be skipped by the guard.
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
// Give the second tick ample time to (wrongly) start a pass; the write count must
// stay at 1, proving no second concurrent pass was launched.
Assert.Equal(1, repository.WriteCount);
Thread.Sleep(300);
Assert.Equal(1, repository.WriteCount);
// Release the gate so the first pass completes and the guard is lowered; a fresh
// tick must now run a new pass (guard correctly reset, not stuck). Re-send the tick on
// each poll so we don't race the (asynchronous) SampleComplete that lowers the guard —
// a tick that lands before the guard clears is harmlessly skipped, the next one runs.
repository.Release();
AwaitAssert(
() =>
{
actor.Tell(KpiHistoryRecorderActor.SampleTick.Instance);
Assert.Equal(2, repository.WriteCount);
},
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));
}
// =====================================================================
// One-shot rollup backfill (Task 6)
// =====================================================================
[Fact]
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. 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 = 5;
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;
// 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);
var expectedFromHour = expectedToHour - TimeSpan.FromDays(retentionDays);
// 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(5),
interval: TimeSpan.FromMilliseconds(50));
// 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(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 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, 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);
AwaitAssert(
() => Assert.Equal(1, repository.FoldCount),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// Periodic rollup tick while the backfill is 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 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 BackfillSliceComplete 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 Backfill_Skips_WhenRollupsAlreadyCurrent()
{
// Watermark within RollupLookbackHours of now (the common failover case): the backfill
// must be skipped entirely — ZERO folds, the done-latch set — because the periodic fold's
// lookback window already covers everything from the watermark forward (arch-review 04
// round 2, R1).
var repository = new RecordingRepository();
var sp = BuildServiceProvider(repository, new HealthySource());
var actor = CreateActor(sp, new KpiHistoryOptions
{
SampleInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromHours(1),
RollupInterval = TimeSpan.FromHours(1),
RetentionDays = 90,
RollupLookbackHours = 3,
});
// Rollups current through one hour ago — well inside the 3 h lookback tail.
var nowHour = new DateTime(
DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day,
DateTime.UtcNow.Hour, 0, 0, DateTimeKind.Utc);
repository.LatestRollupHour = nowHour - TimeSpan.FromHours(1);
actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);
// The plan pass must consult the watermark…
AwaitAssert(
() => Assert.True(repository.LatestRollupHourQueryCount >= 1),
duration: TimeSpan.FromSeconds(3),
interval: TimeSpan.FromMilliseconds(50));
// …and then skip: no slices are ever folded.
Thread.Sleep(300);
Assert.Equal(0, repository.FoldCount);
// A second BackfillTick is a no-op (the done-latch is set).
actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);
Thread.Sleep(300);
Assert.Equal(0, repository.FoldCount);
}
[Fact]
public void Backfill_ShrinksToWatermark_InsteadOfRetentionFloor()
{
// RetentionDays = 90 but the watermark is only 2 days old: the first slice must start at
// the WATERMARK hour (inclusive — re-folding the newest already-folded hour is a cheap
// idempotent safety margin), NOT 90 days ago, and there must be <= 3 slices total
// (arch-review 04 round 2, R1).
var repository = new RecordingRepository();
var sp = BuildServiceProvider(repository, new HealthySource());
var actor = CreateActor(sp, new KpiHistoryOptions
{
SampleInterval = TimeSpan.FromHours(1),
PurgeInterval = TimeSpan.FromHours(1),
RollupInterval = TimeSpan.FromHours(1),
RetentionDays = 90,
RollupLookbackHours = 3,
});
var nowHour = new DateTime(
DateTime.UtcNow.Year, DateTime.UtcNow.Month, DateTime.UtcNow.Day,
DateTime.UtcNow.Hour, 0, 0, DateTimeKind.Utc);
var watermark = nowHour - TimeSpan.FromDays(2);
repository.LatestRollupHour = watermark;
actor.Tell(KpiHistoryRecorderActor.BackfillTick.Instance);
AwaitAssert(
() =>
{
var windows = repository.FoldWindows;
Assert.NotEmpty(windows);
// Shrunk to the un-rolled tail: first slice starts AT the watermark, not 90 d ago.
Assert.Equal(watermark, windows[0].FromHourUtc);
Assert.True(windows.Count <= 3, $"expected <= 3 slices from the 2-day tail, got {windows.Count}");
},
duration: TimeSpan.FromSeconds(5),
interval: TimeSpan.FromMilliseconds(50));
}
}