feat(audit-log): SiteAuditRetentionOptions (7-day default, clamped 1-90)

RetentionDays clamped [1,90]; PurgeInterval clamped to >= 1 min (Timer spin
footgun); 5-min InitialDelay so a daily-recycled node still purges. (PLAN-04 Task 2)
This commit is contained in:
Joseph Doherty
2026-07-09 06:13:05 -04:00
parent 731ee69797
commit b5271da5c8
2 changed files with 93 additions and 0 deletions
@@ -0,0 +1,60 @@
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;
/// <summary>
/// Configuration for the site-side SQLite retention purge (PLAN-04 Task 2, S1/U2).
/// The site audit store is ephemeral (~7-day retention); <see cref="SiteAuditRetentionService"/>
/// runs <see cref="ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.ISiteAuditQueue.PurgeExpiredAsync"/>
/// on a timer using these knobs. Mirrors the resolve/clamp shape of
/// <c>SiteCallAuditOptions</c> so a misconfigured value can never make the timer spin
/// or the retention window collapse.
/// </summary>
public class SiteAuditRetentionOptions
{
/// <summary>Configuration section path bound in the site host.</summary>
public const string SectionName = "ScadaBridge:AuditLog:SiteRetention";
/// <summary>
/// Retention window in days. On each purge tick, Forwarded/Reconciled rows whose
/// <c>OccurredAtUtc</c> is older than <c>UtcNow - ResolvedRetentionDays</c> are
/// deleted (Pending rows are never purged on age alone). Default 7. Resolved value
/// is clamped to <see cref="MinRetentionDays"/>..<see cref="MaxRetentionDays"/>.
/// </summary>
public int RetentionDays { get; set; } = 7;
private const int MinRetentionDays = 1;
private const int MaxRetentionDays = 90;
/// <summary>
/// Effective retention window: <see cref="RetentionDays"/> clamped to
/// [<see cref="MinRetentionDays"/>, <see cref="MaxRetentionDays"/>] so a
/// misconfigured 0 (which would purge everything) or an unbounded value can
/// never take effect.
/// </summary>
public int ResolvedRetentionDays =>
RetentionDays < MinRetentionDays ? MinRetentionDays
: RetentionDays > MaxRetentionDays ? MaxRetentionDays
: RetentionDays;
/// <summary>
/// Period of the purge tick. Default 24 hours. Resolved value is clamped to at
/// least <see cref="MinPurgeInterval"/> so a zero/negative config value cannot
/// make the timer fire in a tight loop.
/// </summary>
public TimeSpan PurgeInterval { get; set; } = TimeSpan.FromHours(24);
private static readonly TimeSpan MinPurgeInterval = TimeSpan.FromMinutes(1);
/// <summary>
/// Effective purge period: <see cref="PurgeInterval"/> clamped to at least
/// <see cref="MinPurgeInterval"/> (the Timer/Akka zero-interval spin footgun).
/// </summary>
public TimeSpan ResolvedPurgeInterval =>
PurgeInterval < MinPurgeInterval ? MinPurgeInterval : PurgeInterval;
/// <summary>
/// Delay before the first purge tick. Default 5 minutes — short so a node that
/// restarts daily (or more often) still runs the purge shortly after start
/// rather than waiting a full <see cref="PurgeInterval"/> it may never reach.
/// </summary>
public TimeSpan InitialDelay { get; set; } = TimeSpan.FromMinutes(5);
}
@@ -0,0 +1,33 @@
using ZB.MOM.WW.ScadaBridge.AuditLog.Site;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site;
/// <summary>
/// Tests for <see cref="SiteAuditRetentionOptions"/> clamping (PLAN-04 Task 2).
/// The resolved knobs guard the daily site retention job against misconfiguration:
/// retention days are clamped to [1, 90]; the purge interval is clamped away from
/// TimeSpan.Zero (an Akka/Timer spin footgun).
/// </summary>
public class SiteAuditRetentionOptionsTests
{
[Theory]
[InlineData(0, 1)] // clamp floor
[InlineData(7, 7)] // default passthrough
[InlineData(365, 90)] // clamp ceiling (spec: min 1, max 90)
public void ResolvedRetentionDays_Clamps(int configured, int expected)
=> Assert.Equal(expected, new SiteAuditRetentionOptions { RetentionDays = configured }.ResolvedRetentionDays);
[Fact]
public void Defaults_Are_SevenDays_DailyPurge_FiveMinuteInitialDelay()
{
var o = new SiteAuditRetentionOptions();
Assert.Equal(7, o.ResolvedRetentionDays);
Assert.Equal(TimeSpan.FromHours(24), o.ResolvedPurgeInterval);
Assert.Equal(TimeSpan.FromMinutes(5), o.InitialDelay);
}
[Fact]
public void ResolvedPurgeInterval_ClampsZeroToOneMinute()
=> Assert.Equal(TimeSpan.FromMinutes(1),
new SiteAuditRetentionOptions { PurgeInterval = TimeSpan.Zero }.ResolvedPurgeInterval);
}