diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs index 71d9cf11..e9c9d5e1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeActor.cs @@ -167,6 +167,9 @@ public class AuditLogPurgeActor : ReceiveActor if (boundaries.Count == 0) { + // No whole-month partitions are eligible, but per-channel overrides may + // still expire rows earlier than the global window — run them below. + await RunPerChannelOverridesAsync(repository).ConfigureAwait(false); return; } @@ -202,6 +205,80 @@ public class AuditLogPurgeActor : ReceiveActor sw.ElapsedMilliseconds); } } + + // M5.5 (T3): after the channel-blind global partition switch-out, apply any + // per-channel retention overrides that are SHORTER than the global window via + // a bounded, batched row DELETE on the same maintenance path. The global + // switch-out has already dropped whole months older than RetentionDays; these + // deletes only ever expire rows EARLIER than that, so they run last and are a + // strict tightening. + await RunPerChannelOverridesAsync(repository).ConfigureAwait(false); + } + + /// + /// M5.5 (T3): runs each per-channel retention override whose window is strictly + /// shorter than the global , deleting + /// rows of that channel older than the channel-specific threshold via a bounded, + /// batched maintenance-path DELETE. Each channel runs inside its own try/catch so + /// one bad channel does not abandon the others on the same tick, mirroring the + /// per-boundary error isolation of the partition switch-out loop. + /// + /// The repository resolved for this tick's DI scope. + private async Task RunPerChannelOverridesAsync(IAuditLogRepository repository) + { + var overrides = _auditOptions.PerChannelRetentionDays; + if (overrides is null || overrides.Count == 0) + { + return; + } + + var globalDays = _auditOptions.RetentionDays; + + foreach (var (channel, days) in overrides) + { + // Only act when the per-channel window is strictly shorter than the global + // one. Equal/longer windows are already covered by the global partition + // switch-out, so a row DELETE would be redundant work (and a longer window + // is meaningless — the partition is dropped on the global schedule). + if (days >= globalDays) + { + continue; + } + + var channelThreshold = DateTime.UtcNow - TimeSpan.FromDays(days); + var sw = Stopwatch.StartNew(); + try + { + var rowsDeleted = await repository + .PurgeChannelOlderThanAsync(channel, channelThreshold, _purgeOptions.ChannelPurgeBatchSize) + .ConfigureAwait(false); + sw.Stop(); + + if (rowsDeleted > 0) + { + _logger.LogInformation( + "Purged {RowsDeleted} AuditLog rows for channel {Channel} older than {Threshold:o} " + + "(per-channel override {Days}d < global {GlobalDays}d) in {DurationMs} ms.", + rowsDeleted, + channel, + channelThreshold, + days, + globalDays, + sw.ElapsedMilliseconds); + } + } + catch (Exception ex) + { + sw.Stop(); + _logger.LogError( + ex, + "Failed to apply per-channel retention override for channel {Channel} " + + "({Days}d); other channels continue. Elapsed {DurationMs} ms.", + channel, + days, + sw.ElapsedMilliseconds); + } + } } /// Self-tick triggering a purge pass across all eligible partitions. diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeOptions.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeOptions.cs index 0ba1bc00..5a0906cf 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Central/AuditLogPurgeOptions.cs @@ -28,6 +28,24 @@ public sealed class AuditLogPurgeOptions /// Period of the purge tick in hours (default 24). public int IntervalHours { get; set; } = 24; + /// + /// M5.5 (T3): batch size for the per-channel retention-override row DELETE + /// (). + /// Each DELETE TOP (@batch) caps the transaction-log and lock footprint + /// per statement; the repository loops batches until no rows remain. Default + /// 5000 keeps individual deletes short on a busy central DB while still draining + /// a large backlog within a tick. Clamped to a sane minimum in + /// . + /// + public int ChannelPurgeBatchSizeConfigured { get; set; } = 5000; + + /// + /// Resolves the effective per-channel purge batch size, clamped to at least 1 so + /// a misconfigured 0/negative value cannot make the repository's DELETE + /// loop spin or throw. + /// + public int ChannelPurgeBatchSize => ChannelPurgeBatchSizeConfigured < 1 ? 1 : ChannelPurgeBatchSizeConfigured; + /// /// Test-only override for finer control over the tick cadence than /// whole-hour resolution allows. When non-null, takes precedence over diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptions.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptions.cs index 75a57441..12369897 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptions.cs @@ -37,6 +37,33 @@ public sealed class AuditLogOptions /// Central retention window in days (default 365, range [30, 3650]). public int RetentionDays { get; set; } = 365; + /// + /// M5.5 (T3) per-channel retention overrides, keyed by the canonical channel name + /// (the enum name — e.g. ApiOutbound, + /// DbOutbound, Notification, ApiInbound). The value is a + /// retention window in days that MUST be SHORTER than or equal to the global + /// . + /// + /// + /// + /// The global window is enforced by month-partition + /// switch-out, which is channel-blind: it can only drop a whole month once every + /// row in it is older than the global window. A per-channel override therefore + /// can only ever expire rows EARLIER than the global purge would — never later + /// (a longer per-channel window is meaningless because the partition switch-out + /// would already have dropped the month). Overrides shorter than the global window + /// are honoured by the purge actor as a bounded, batched row DELETE on the + /// maintenance path (see AuditLogPurgeActor); the append-only writer/ingest + /// role is unaffected. + /// + /// + /// Each value is validated to be in [30, RetentionDays] by + /// AuditLogOptionsValidator; keys that are not recognized + /// names are rejected. + /// + /// + public Dictionary PerChannelRetentionDays { get; set; } = new(); + /// /// Per-body byte ceiling applied to and /// for rows diff --git a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptionsValidator.cs index 91863980..0406d99c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.AuditLog/Configuration/AuditLogOptionsValidator.cs @@ -1,4 +1,5 @@ using ZB.MOM.WW.Configuration; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Configuration; @@ -52,5 +53,27 @@ public sealed class AuditLogOptionsValidator : OptionsValidatorBase MaxInboundMaxBytes), $"AuditLog:{nameof(AuditLogOptions.InboundMaxBytes)} ({options.InboundMaxBytes}) " + $"must be in [{MinInboundMaxBytes}, {MaxInboundMaxBytes}] bytes."); + + // M5.5 (T3): per-channel retention overrides. Each entry must be keyed by a + // recognized AuditChannel name and carry a window in [MinRetentionDays, + // RetentionDays] — i.e. SHORTER than or equal to the global window. A longer + // per-channel window is meaningless under month-partition switch-out (governed + // by the global window), so it is rejected rather than silently ignored. + foreach (var (channelKey, days) in options.PerChannelRetentionDays) + { + builder.RequireThat( + Enum.TryParse(channelKey, ignoreCase: false, out _), + $"AuditLog:{nameof(AuditLogOptions.PerChannelRetentionDays)} key '{channelKey}' " + + $"is not a recognized channel name. Valid keys: {string.Join(", ", Enum.GetNames())}."); + + // Valid when days is within [MinRetentionDays, RetentionDays] inclusive. + // The lower bound matches the global RetentionDays floor; the upper bound + // is the configured global window (longer is meaningless — see remarks). + builder.RequireThat( + !(days < MinRetentionDays || days > options.RetentionDays), + $"AuditLog:{nameof(AuditLogOptions.PerChannelRetentionDays)}['{channelKey}'] ({days}) " + + $"must be in [{MinRetentionDays}, {nameof(AuditLogOptions.RetentionDays)}={options.RetentionDays}] days " + + "— a per-channel window must be shorter than or equal to the global retention window."); + } } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs index 6f66cde2..8eec2b87 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IAuditLogRepository.cs @@ -87,6 +87,42 @@ public interface IAuditLogRepository /// A task that resolves to the approximate number of rows discarded by the partition switch. Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default); + /// + /// M5.5 (T3) per-channel retention override purge. Deletes AuditLog rows for a + /// single (matched against the canonical + /// Category column — the bare channel name, e.g. ApiOutbound) whose + /// OccurredAtUtc is strictly older than , in + /// bounded batches of rows, looping until no further + /// rows match. Returns the total number of rows deleted across all batches. + /// + /// + /// + /// Maintenance path — NOT the writer role. The append-only invariant binds + /// the scadabridge_audit_writer ingest role (INSERT + SELECT only). This row + /// DELETE runs on the purge/maintenance connection, the same path that performs the + /// global partition switch-out (also a destructive operation forbidden to the writer + /// role). Per-channel overrides can only ever expire rows EARLIER than the global + /// month-partition switch-out would — never later — so this is a strict tightening + /// of the retention window, applied AFTER the global purge on the same tick. + /// + /// + /// Bounded + idempotent. Each batch is a DELETE TOP (@batch) so the + /// transaction log and lock footprint stay bounded regardless of backlog. Re-running + /// the purge is a no-op once every eligible row is gone (the loop exits when a batch + /// deletes zero rows), so a crash mid-loop is recoverable by simply running again. + /// + /// + /// Canonical channel name (the Category column value, e.g. ApiOutbound). + /// Rows with OccurredAtUtc strictly older than this UTC datetime are deleted. + /// Maximum rows deleted per batch; must be > 0. + /// Cancellation token. + /// A task that resolves to the total number of rows deleted across all batches. + Task PurgeChannelOlderThanAsync( + string channel, + DateTime threshold, + int batchSize, + CancellationToken ct = default); + /// /// Returns the set of pf_AuditLog_Month partition lower-bound /// boundaries whose partitions contain only rows with diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs index 0fd3ffd7..470849ff 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/AuditLogRepository.cs @@ -370,6 +370,99 @@ VALUES return rowsDeleted; } + /// + public async Task PurgeChannelOlderThanAsync( + string channel, + DateTime threshold, + int batchSize, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(channel)) + { + throw new ArgumentException("Channel must be a non-empty channel name.", nameof(channel)); + } + + if (batchSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(batchSize), batchSize, "Batch size must be > 0."); + } + + var thresholdUtc = DateTime.SpecifyKind(threshold.ToUniversalTime(), DateTimeKind.Utc); + + // M5.5 (T3) per-channel retention override purge. This is the ONLY DELETE + // against dbo.AuditLog in the codebase and it runs on the purge/maintenance + // path, NOT the append-only writer role (which has INSERT + SELECT only — see + // the DENY UPDATE/DENY DELETE grants in CollapseAuditLogToCanonical). The + // AuditLog append-only CI guard (AuditLogAppendOnlyGuardTests) is intentionally + // widened to allow ONLY the single marked DELETE below; any other UPDATE/DELETE + // targeting AuditLog still trips the guard. + // + // Bounded + idempotent: DELETE TOP (@batch) caps the log/lock footprint per + // statement; the loop repeats until a batch deletes zero rows, so re-running + // after a crash mid-loop simply resumes. Category is the canonical + // channel-name column (e.g. 'ApiOutbound'); Action holds "{channel}.{kind}" so + // it is NOT the right column to match a bare channel name against. + // + // The trailing AUDIT-PURGE-ALLOWED marker on the DELETE line below is the + // single narrow exemption the append-only CI guard (AuditLogAppendOnlyGuardTests) + // recognizes; any other UPDATE/DELETE targeting AuditLog still trips the guard. + const string deleteBatchSql = + "DELETE TOP (@batch) FROM dbo.AuditLog WHERE Category = @channel AND OccurredAtUtc < @threshold;"; // AUDIT-PURGE-ALLOWED: per-channel retention override (M5.5 T3), maintenance path + + long totalDeleted = 0; + + var conn = _context.Database.GetDbConnection(); + var openedHere = false; + if (conn.State != System.Data.ConnectionState.Open) + { + await conn.OpenAsync(ct).ConfigureAwait(false); + openedHere = true; + } + + try + { + while (true) + { + ct.ThrowIfCancellationRequested(); + + await using var cmd = conn.CreateCommand(); + cmd.CommandText = deleteBatchSql; + + var pBatch = cmd.CreateParameter(); + pBatch.ParameterName = "@batch"; + pBatch.Value = batchSize; + cmd.Parameters.Add(pBatch); + + var pChannel = cmd.CreateParameter(); + pChannel.ParameterName = "@channel"; + pChannel.Value = channel; + cmd.Parameters.Add(pChannel); + + var pThreshold = cmd.CreateParameter(); + pThreshold.ParameterName = "@threshold"; + pThreshold.Value = thresholdUtc; + cmd.Parameters.Add(pThreshold); + + var rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); + if (rows <= 0) + { + break; + } + + totalDeleted += rows; + } + } + finally + { + if (openedHere) + { + await conn.CloseAsync().ConfigureAwait(false); + } + } + + return totalDeleted; + } + /// public async Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs index 7c90df45..441b6384 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogIngestActorTests.cs @@ -216,6 +216,10 @@ public class AuditLogIngestActorTests : TestKit, IClassFixture SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) => _inner.SwitchOutPartitionAsync(monthBoundary, ct); + public Task PurgeChannelOlderThanAsync( + string channel, DateTime threshold, int batchSize, CancellationToken ct = default) => + _inner.PurgeChannelOlderThanAsync(channel, threshold, batchSize, ct); + public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => _inner.GetPartitionBoundariesOlderThanAsync(threshold, ct); diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs index 228d7446..1020176f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/AuditLogPurgeActorTests.cs @@ -51,6 +51,12 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture ChannelPurges { get; } = new(); + public Func RowsPerChannel { get; set; } = _ => 0L; + // The actor enumerator returns whichever list is configured here. // Mutating this between ticks lets tests simulate "no longer // eligible" boundaries on the second tick. @@ -80,6 +86,13 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture>(Boundaries.ToArray()); } + public Task PurgeChannelOlderThanAsync( + string channel, DateTime threshold, int batchSize, CancellationToken ct = default) + { + ChannelPurges.Add((channel, threshold, batchSize)); + return Task.FromResult(RowsPerChannel(channel)); + } + public Task GetKpiSnapshotAsync( TimeSpan window, DateTime? nowUtc = null, CancellationToken ct = default) => Task.FromResult(new ZB.MOM.WW.ScadaBridge.Commons.Types.AuditLogKpiSnapshot(0L, 0L, 0L, nowUtc ?? DateTime.UtcNow)); @@ -381,4 +394,90 @@ public class AuditLogPurgeActorTests : TestKit, IClassFixture() }; + var purgeOptions = FastTickOptions(); + purgeOptions.ChannelPurgeBatchSizeConfigured = 1234; + + // Build the options OUTSIDE the Props expression tree — a collection/dictionary + // initializer is not legal inside an expression-tree lambda (CS8074). + var auditOptions = Options.Create(new AuditLogOptions + { + RetentionDays = 365, + PerChannelRetentionDays = new Dictionary { ["ApiOutbound"] = 30 }, + }); + var purgeOptionsWrapped = Options.Create(purgeOptions); + + var sp = BuildScopedProvider(repo); + Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor( + sp, + purgeOptionsWrapped, + auditOptions, + NullLogger.Instance))); + + AwaitAssert( + () => Assert.Contains(repo.ChannelPurges, p => p.Channel == "ApiOutbound"), + duration: TimeSpan.FromSeconds(3), + interval: TimeSpan.FromMilliseconds(50)); + + var purge = repo.ChannelPurges.First(p => p.Channel == "ApiOutbound"); + Assert.Equal(1234, purge.BatchSize); + + var expected = DateTime.UtcNow - TimeSpan.FromDays(30); + Assert.True( + Math.Abs((purge.Threshold - expected).TotalMinutes) < 1.0, + $"channel threshold {purge.Threshold:o} should be within 1 minute of {expected:o}"); + } + + // --------------------------------------------------------------------- + // 9. PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge (M5.5 T3) + // --------------------------------------------------------------------- + + [Fact] + public void PerChannelOverride_EqualOrLongerThanGlobal_SkipsChannelPurge() + { + // DbOutbound = 365 (== global) and Notification = 400 (> global, validator would + // normally reject this but the actor must defensively skip it too). Neither is + // SHORTER than the global window, so the actor must NOT issue a channel purge — + // the global partition switch-out already governs those rows. + var repo = new RecordingRepo { Boundaries = new List() }; + + // Build the options OUTSIDE the Props expression tree (CS8074). + var auditOptions = Options.Create(new AuditLogOptions + { + RetentionDays = 365, + PerChannelRetentionDays = new Dictionary + { + ["DbOutbound"] = 365, + ["Notification"] = 400, + }, + }); + var purgeOptions = Options.Create(FastTickOptions()); + + var sp = BuildScopedProvider(repo); + Sys.ActorOf(Props.Create(() => new AuditLogPurgeActor( + sp, + purgeOptions, + auditOptions, + NullLogger.Instance))); + + // Wait for at least one tick (visible via the enumerator call), then assert no + // channel purge was issued. + AwaitAssert( + () => Assert.True(repo.ThresholdQueries.Count >= 1), + duration: TimeSpan.FromSeconds(3), + interval: TimeSpan.FromMilliseconds(50)); + + Assert.Empty(repo.ChannelPurges); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs index 823a2314..8fc72824 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/CentralAuditWriteFailuresTests.cs @@ -44,6 +44,9 @@ public class CentralAuditWriteFailuresTests : TestKit Task.FromResult>(Array.Empty()); public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) => Task.FromResult(0L); + public Task PurgeChannelOlderThanAsync( + string channel, DateTime threshold, int batchSize, CancellationToken ct = default) => + Task.FromResult(0L); public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs index 8128ad95..e795742f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Central/SiteAuditReconciliationActorTests.cs @@ -89,6 +89,10 @@ public class SiteAuditReconciliationActorTests : TestKit, IClassFixture SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) => Task.FromResult(0L); + public Task PurgeChannelOlderThanAsync( + string channel, DateTime threshold, int batchSize, CancellationToken ct = default) => + Task.FromResult(0L); + public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs index 3defd29e..b3207e42 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Configuration/AuditLogOptionsValidatorTests.cs @@ -50,4 +50,107 @@ public class AuditLogOptionsValidatorTests result.Failures!, f => f.Contains(nameof(AuditLogOptions.InboundMaxBytes), StringComparison.Ordinal)); } + + // --------------------------------------------------------------------- + // M5.5 (T3) per-channel retention overrides + // --------------------------------------------------------------------- + + [Fact] + public void Validate_PerChannelRetention_ShorterThanGlobal_Passes() + { + // A per-channel window strictly shorter than the global window is the + // sanctioned case — the purge actor expires those rows earlier via the + // maintenance-path row DELETE. + var validator = new AuditLogOptionsValidator(); + var opts = new AuditLogOptions + { + RetentionDays = 365, + PerChannelRetentionDays = new Dictionary + { + ["ApiOutbound"] = 90, + ["Notification"] = 30, // floor (MinRetentionDays) + }, + }; + + Assert.True(validator.Validate(null, opts).Succeeded); + } + + [Fact] + public void Validate_PerChannelRetention_EqualToGlobal_Passes() + { + // Equal to global is allowed (the bound is [Min, RetentionDays] inclusive); + // the purge actor simply treats it as a no-op since it is not SHORTER. + var validator = new AuditLogOptionsValidator(); + var opts = new AuditLogOptions + { + RetentionDays = 200, + PerChannelRetentionDays = new Dictionary { ["DbOutbound"] = 200 }, + }; + + Assert.True(validator.Validate(null, opts).Succeeded); + } + + [Fact] + public void Validate_PerChannelRetention_LongerThanGlobal_Fails() + { + // A per-channel window LONGER than the global window is meaningless under + // month-partition switch-out (governed by the global window) and is rejected. + var validator = new AuditLogOptionsValidator(); + var opts = new AuditLogOptions + { + RetentionDays = 100, + PerChannelRetentionDays = new Dictionary { ["ApiInbound"] = 200 }, + }; + + var result = validator.Validate(null, opts); + Assert.False(result.Succeeded); + Assert.Contains( + result.Failures!, + f => f.Contains(nameof(AuditLogOptions.PerChannelRetentionDays), StringComparison.Ordinal) + && f.Contains("ApiInbound", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_PerChannelRetention_BelowMinimum_Fails() + { + var validator = new AuditLogOptionsValidator(); + var opts = new AuditLogOptions + { + RetentionDays = 365, + PerChannelRetentionDays = new Dictionary { ["ApiOutbound"] = 29 }, + }; + + var result = validator.Validate(null, opts); + Assert.False(result.Succeeded); + Assert.Contains( + result.Failures!, + f => f.Contains(nameof(AuditLogOptions.PerChannelRetentionDays), StringComparison.Ordinal)); + } + + [Fact] + public void Validate_PerChannelRetention_UnknownChannelKey_Fails() + { + // Keys must be recognized AuditChannel names; a typo / unknown key is rejected + // rather than silently ignored so a misconfiguration surfaces at boot. + var validator = new AuditLogOptionsValidator(); + var opts = new AuditLogOptions + { + RetentionDays = 365, + PerChannelRetentionDays = new Dictionary { ["NotAChannel"] = 90 }, + }; + + var result = validator.Validate(null, opts); + Assert.False(result.Succeeded); + Assert.Contains( + result.Failures!, + f => f.Contains("NotAChannel", StringComparison.Ordinal)); + } + + [Fact] + public void Validate_PerChannelRetention_DefaultEmpty_Passes() + { + // The default (no overrides) must pass — this is the common case. + var validator = new AuditLogOptionsValidator(); + Assert.True(validator.Validate(null, new AuditLogOptions()).Succeeded); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs index f26cc40e..c8bc2d7d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/PartitionPurgeTests.cs @@ -67,19 +67,25 @@ public class PartitionPurgeTests : TestKit, IClassFixture SqlConnection conn, Guid eventId, DateTime occurredAtUtc, - string siteId) + string siteId, + string channel = "ApiOutbound", + string kind = "ApiCall") { await using var cmd = conn.CreateCommand(); // C5 (Task 2.5): dbo.AuditLog is now the 10 canonical columns + DetailsJson; // the ScadaBridge domain fields (channel/kind/status/sourceSiteId) ride in // DetailsJson and the SourceSiteId/Kind/Status computed columns auto-derive. // Action = "{channel}.{kind}", Category = channel name, Outcome = Success. + // The channel/kind are parameterized so the M5.5 per-channel purge test can + // seed multiple channels into the same partition. cmd.CommandText = @" INSERT INTO dbo.AuditLog (EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson) VALUES - (@EventId, @OccurredAtUtc, NULL, 'ApiOutbound.ApiCall', 'Success', 'ApiOutbound', NULL, NULL, NULL, + (@EventId, @OccurredAtUtc, NULL, @Action, 'Success', @Category, NULL, NULL, NULL, @DetailsJson);"; + cmd.Parameters.Add("@Action", System.Data.SqlDbType.VarChar, 64).Value = $"{channel}.{kind}"; + cmd.Parameters.Add("@Category", System.Data.SqlDbType.VarChar, 32).Value = channel; cmd.Parameters.Add("@EventId", System.Data.SqlDbType.UniqueIdentifier).Value = eventId; // SqlDbType.DateTime2 with explicit Scale 7 matches the // OccurredAtUtc column shape (datetime2(7)) and avoids the implicit @@ -97,7 +103,7 @@ VALUES // the computed SourceSiteId column the verify queries scope on. payloadTruncated // is always present (the codec always writes the bool). var detailsJson = - "{\"channel\":\"ApiOutbound\",\"kind\":\"ApiCall\",\"status\":\"Delivered\"," + + "{\"channel\":\"" + channel + "\",\"kind\":\"" + kind + "\",\"status\":\"Delivered\"," + "\"sourceSiteId\":\"" + siteId + "\",\"payloadTruncated\":false}"; cmd.Parameters.Add("@DetailsJson", System.Data.SqlDbType.NVarChar, -1).Value = detailsJson; await cmd.ExecuteNonQueryAsync(); @@ -354,4 +360,87 @@ WHERE name = 'UX_AuditLog_EventId' Assert.Single(rows); Assert.Equal(freshEventId, rows[0].EventId); } + + // --------------------------------------------------------------------- + // 4. PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows (M5.5 T3) + // --------------------------------------------------------------------- + + /// + /// M5.5 (T3): exercises + /// directly against the real repository + fixture DB. Seeds, in the SAME partition, + /// old + recent rows for an OVERRIDDEN channel (ApiOutbound) and old + recent + /// rows for an UN-overridden channel (DbOutbound), then runs the per-channel + /// purge for ApiOutbound only. Asserts: + /// + /// The overridden channel's OLD rows are deleted. + /// The overridden channel's RECENT rows (newer than the channel threshold) survive. + /// The un-overridden channel's rows (old AND recent) are completely untouched + /// — they follow the global window, which the channel purge never applies to them. + /// + /// This is the maintenance-path row DELETE; the fixture connects as sa, which + /// the append-only writer-role DENYs do not bind (the role granularity is exercised + /// in the repository/migration tests). + /// + [SkippableFact] + public async Task PerChannelOverride_DeletesOnlyOverriddenChannelsOldRows() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = "perchannel-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + // Two timestamps: one OLD (older than the channel threshold we will purge with) + // and one RECENT (newer than it). Both sit comfortably inside the retention + // window so the global partition purge would NOT touch either — isolating the + // per-channel DELETE as the only force acting here. + var oldOccurred = new DateTime(2026, 1, 15, 0, 0, 0, DateTimeKind.Utc); + var recentOccurred = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc); + + var apiOldId = Guid.NewGuid(); // ApiOutbound, old → SHOULD be deleted + var apiRecentId = Guid.NewGuid(); // ApiOutbound, recent→ SHOULD survive + var dbOldId = Guid.NewGuid(); // DbOutbound, old → SHOULD survive (un-overridden) + var dbRecentId = Guid.NewGuid(); // DbOutbound, recent → SHOULD survive + + await using (var seedConn = _fixture.OpenConnection()) + { + await DirectInsertAsync(seedConn, apiOldId, oldOccurred, siteId, channel: "ApiOutbound", kind: "ApiCall"); + await DirectInsertAsync(seedConn, apiRecentId, recentOccurred, siteId, channel: "ApiOutbound", kind: "ApiCall"); + await DirectInsertAsync(seedConn, dbOldId, oldOccurred, siteId, channel: "DbOutbound", kind: "DbWrite"); + await DirectInsertAsync(seedConn, dbRecentId, recentOccurred, siteId, channel: "DbOutbound", kind: "DbWrite"); + } + + // Purge ApiOutbound rows older than a threshold that sits strictly between the + // old (Jan 15) and recent (May 15) seeds — e.g. Mar 1. Only apiOldId qualifies. + var channelThreshold = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc); + + await using (var ctx = CreateContext()) + { + var repo = new AuditLogRepository(ctx); + var deleted = await repo.PurgeChannelOlderThanAsync( + channel: "ApiOutbound", + threshold: channelThreshold, + batchSize: 2); + + Assert.Equal(1L, deleted); + + // Idempotent: a second run deletes nothing (the eligible row is gone). + var deletedAgain = await repo.PurgeChannelOlderThanAsync( + channel: "ApiOutbound", + threshold: channelThreshold, + batchSize: 2); + Assert.Equal(0L, deletedAgain); + } + + await using var verify = CreateContext(); + var rows = await verify.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + + // Overridden channel: old gone, recent kept. + Assert.DoesNotContain(rows, r => r.EventId == apiOldId); + Assert.Contains(rows, r => r.EventId == apiRecentId); + + // Un-overridden channel: BOTH rows untouched (follow the global window). + Assert.Contains(rows, r => r.EventId == dbOldId); + Assert.Contains(rows, r => r.EventId == dbRecentId); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs index 09b6a1f1..3a4fe894 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditLog/SiteAuditPushFlowTests.cs @@ -89,6 +89,10 @@ public class SiteAuditPushFlowTests : TestKit public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default) => throw new NotSupportedException(); + public Task PurgeChannelOlderThanAsync( + string channel, DateTime threshold, int batchSize, CancellationToken ct = default) + => throw new NotSupportedException(); + public Task> GetPartitionBoundariesOlderThanAsync( DateTime threshold, CancellationToken ct = default) => throw new NotSupportedException();