using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.AuditLog.Site; using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Site; /// /// Forward-state lifecycle regressions for review 2026-10-21 06/PERF-1 and 06/IMPL-1: the /// reconciliation pull serves only rows central has NOT acknowledged (never Forwarded), the /// push-exit states and /// stay owed to the pull until a covering cursor /// retires them, and a row central keeps refusing leaves the push queue after N attempts. /// public class SqliteAuditWriterForwardStateTests : IDisposable { private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"audit-fwdstate-{Guid.NewGuid():N}.db"); /// Deletes the temporary database. public void Dispose() { foreach (var suffix in new[] { "", "-wal", "-shm" }) { try { File.Delete(_dbPath + suffix); } catch { /* best-effort */ } } } private SqliteAuditWriter CreateWriter() => new( Options.Create(new SqliteAuditWriterOptions { DatabasePath = _dbPath }), NullLogger.Instance, new FakeNodeIdentityProvider()); private static async Task WriteAsync(SqliteAuditWriter writer, DateTime occurredAtUtc) { var id = Guid.NewGuid(); await writer.WriteAsync(ScadaBridgeAuditEventFactory.Create( eventId: id, occurredAtUtc: occurredAtUtc, channel: AuditChannel.ApiOutbound, kind: AuditKind.ApiCall, status: AuditStatus.Delivered)); return id; } /// The pull no longer re-ships rows the push already delivered. /// A task that represents the asynchronous operation. [Fact] public async Task ReadPendingSince_ExcludesForwardedRows() { await using var writer = CreateWriter(); var t = DateTime.UtcNow.AddMinutes(-5); var forwarded = await WriteAsync(writer, t); var pending = await WriteAsync(writer, t.AddSeconds(1)); await writer.MarkForwardedAsync(new[] { forwarded }); var served = await writer.ReadPendingSinceAsync(DateTime.MinValue, 100); Assert.Equal(pending, Assert.Single(served).EventId); } /// A covering cursor retires only owed rows and leaves Forwarded rows alone. /// A task that represents the asynchronous operation. [Fact] public async Task MarkReconciledUpTo_DoesNotTouchForwardedRows() { await using var writer = CreateWriter(); var t = DateTime.UtcNow.AddMinutes(-5); var forwarded = await WriteAsync(writer, t); var pending = await WriteAsync(writer, t.AddSeconds(1)); await writer.MarkForwardedAsync(new[] { forwarded }); var served = await writer.ReadPendingSinceAsync(DateTime.MinValue, 100); var flipped = await writer.MarkReconciledUpToAsync( served[^1].OccurredAtUtc.UtcDateTime, served[^1].EventId.ToString()); Assert.Equal(1, flipped); Assert.Equal("Forwarded", writer.GetForwardStateForTests(forwarded)!.Value.State); Assert.Equal("Reconciled", writer.GetForwardStateForTests(pending)!.Value.State); } /// A push-abandoned row leaves the push queue, is served by the pull, then retires. /// A task that represents the asynchronous operation. [Fact] public async Task PushAbandoned_IsServedByThePull_ThenMarkedReconciled_AndNeverPurgedBeforeThat() { await using var writer = CreateWriter(); var old = DateTime.UtcNow.AddDays(-10); var abandoned = await WriteAsync(writer, old); await writer.MarkPushAbandonedAsync(new[] { abandoned }); Assert.Empty(await writer.ReadPendingAsync(100)); // Still owed: never purged on age, counted on the backlog. Assert.Equal(0, await writer.PurgeExpiredAsync(DateTime.UtcNow)); Assert.Equal(1, (await writer.GetBacklogStatsAsync()).PendingCount); var served = await writer.ReadPendingSinceAsync(DateTime.MinValue, 100); Assert.Equal(abandoned, Assert.Single(served).EventId); await writer.MarkReconciledUpToAsync(old, abandoned.ToString()); Assert.Equal("Reconciled", writer.GetForwardStateForTests(abandoned)!.Value.State); Assert.Equal(1, await writer.PurgeExpiredAsync(DateTime.UtcNow)); } /// /// A row central keeps refusing bumps its attempt history every time and, at the threshold, /// leaves the push queue as Rejected — unwedging the push head — while staying owed to the pull. /// /// A task that represents the asynchronous operation. [Fact] public async Task RefusedRow_BumpsAttempts_ThenFlipsToRejected_UnwedgingThePushHead() { await using var writer = CreateWriter(); var t = DateTime.UtcNow.AddMinutes(-5); var poison = await WriteAsync(writer, t); var behind = await WriteAsync(writer, t.AddSeconds(1)); Assert.Equal(0, await writer.RecordRejectedPushAttemptsAsync(new[] { poison }, maxAttempts: 3)); Assert.Equal(0, await writer.RecordRejectedPushAttemptsAsync(new[] { poison }, maxAttempts: 3)); Assert.Equal(("Pending", 2), writer.GetForwardStateForTests(poison)!.Value); Assert.Equal(poison, (await writer.ReadPendingAsync(1))[0].EventId); // still the head Assert.Equal(1, await writer.RecordRejectedPushAttemptsAsync(new[] { poison }, maxAttempts: 3)); Assert.Equal(("Rejected", 3), writer.GetForwardStateForTests(poison)!.Value); Assert.Equal(behind, Assert.Single(await writer.ReadPendingAsync(1)).EventId); Assert.Contains(await writer.ReadPendingSinceAsync(DateTime.MinValue, 100), e => e.EventId == poison); Assert.Equal(0, await writer.PurgeExpiredAsync(DateTime.UtcNow.AddDays(1))); Assert.Equal(1, writer.RejectedPushRowCount); } /// /// The served high-water mark survives a writer restart: rows served before the restart are /// retired by the first cursor that covers them after it, instead of being stranded owed /// (and counted on the backlog) until something happens to re-serve them. /// /// A task that represents the asynchronous operation. [Fact] public async Task ServedHighWaterMark_SurvivesRestart_SoTheNextCursorStillRetires() { var t = DateTime.UtcNow.AddMinutes(-5); Guid abandoned; IReadOnlyList served; await using (var writer = CreateWriter()) { abandoned = await WriteAsync(writer, t); await writer.MarkPushAbandonedAsync(new[] { abandoned }); served = await writer.ReadPendingSinceAsync(DateTime.MinValue, 100); Assert.Single(served); } await using (var restarted = CreateWriter()) { Assert.True(restarted.MaxServedRowIdForTests > 0); var flipped = await restarted.MarkReconciledUpToAsync( served[0].OccurredAtUtc.UtcDateTime, served[0].EventId.ToString()); Assert.Equal(1, flipped); Assert.Equal("Reconciled", restarted.GetForwardStateForTests(abandoned)!.Value.State); Assert.Equal(0, (await restarted.GetBacklogStatsAsync()).PendingCount); } } /// /// Follow-up L1: the served high-water mark is only ever MUTATED under the write lock — the /// same lock the purge's clamp runs under. Before the fix the read path wrote the mark under /// its own read lock from a value it sampled before the SELECT, so a purge that clamped the /// mark in between was overwritten with the stale-high value, re-vouching for reusable rowids. /// Holding the write lock across a pull must therefore leave the mark untouched until released. /// /// A task that represents the asynchronous operation. [Fact] public async Task ServedHighWaterMark_IsOnlyAdvancedUnderTheWriteLock() { await using var writer = CreateWriter(); await WriteAsync(writer, DateTime.UtcNow.AddMinutes(-5)); var before = writer.MaxServedRowIdForTests; Task> pull; System.Threading.Monitor.Enter(writer.WriteLockForTests); try { pull = Task.Run(() => writer.ReadPendingSinceAsync(DateTime.MinValue, 100)); // The SELECT itself needs only the read lock; give it ample time to run. A // synchronous sleep (not an await) so Monitor.Exit runs on the owning thread. Thread.Sleep(500); Assert.Equal(before, writer.MaxServedRowIdForTests); } finally { System.Threading.Monitor.Exit(writer.WriteLockForTests); } Assert.Single(await pull.WaitAsync(TimeSpan.FromSeconds(10))); Assert.True(writer.MaxServedRowIdForTests > before); } }