feat(auditlog): FallbackAuditWriter compose SQLite + ring + failure counter (#23)
Adds the IAuditWriter composer that sits between the script-side ScriptRuntimeContext audit emission (Bundle F) and the primary SqliteAuditWriter. Honours the alog.md §7 guarantee that audit-write failures NEVER abort the user-facing action: - Primary throw -> log Warning, increment IAuditWriteFailureCounter (Bundle G's health-metric sink), stash the event in the drop-oldest RingBufferFallback, return success to the caller. - Primary success -> opportunistically drain the ring back through the primary in FIFO order, behind the triggering event. Drain is serialised via a SemaphoreSlim gate so concurrent recoveries don't double-replay; a drain-side re-throw re-enqueues at the tail and breaks out (the next successful write retries). Adds IAuditWriteFailureCounter as the lightweight DI seam (one void Increment()), and a TryDequeue helper on RingBufferFallback that the recovery path uses to pop one item without blocking. Tests (4 new, total 26 -> 30): - WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess - WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite (order: trigger first, then ring backlog in submission FIFO) - WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty - WriteAsync_FailureCounter_Incremented_Per_PrimaryFailure
This commit is contained in:
133
tests/ScadaLink.AuditLog.Tests/Site/FallbackAuditWriterTests.cs
Normal file
133
tests/ScadaLink.AuditLog.Tests/Site/FallbackAuditWriterTests.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ScadaLink.AuditLog.Site;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
|
||||
namespace ScadaLink.AuditLog.Tests.Site;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle B (M2-T4) tests for <see cref="FallbackAuditWriter"/> — composes the
|
||||
/// primary <see cref="SqliteAuditWriter"/>, the drop-oldest
|
||||
/// <see cref="RingBufferFallback"/>, and an
|
||||
/// <see cref="IAuditWriteFailureCounter"/> health counter.
|
||||
/// </summary>
|
||||
public class FallbackAuditWriterTests
|
||||
{
|
||||
private static AuditEvent NewEvent(string? target = null) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid(),
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Channel = AuditChannel.ApiOutbound,
|
||||
Kind = AuditKind.ApiCall,
|
||||
Status = AuditStatus.Delivered,
|
||||
Target = target,
|
||||
PayloadTruncated = false,
|
||||
ForwardState = AuditForwardState.Pending,
|
||||
};
|
||||
|
||||
/// <summary>Flip-switch primary writer mock.</summary>
|
||||
private sealed class FlipSwitchPrimary : IAuditWriter
|
||||
{
|
||||
public bool FailNext { get; set; }
|
||||
public List<AuditEvent> Written { get; } = new();
|
||||
|
||||
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||
{
|
||||
if (FailNext)
|
||||
{
|
||||
return Task.FromException(new InvalidOperationException("primary down"));
|
||||
}
|
||||
Written.Add(evt);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_PrimaryThrows_EventLandsInRing_CallReturnsSuccess()
|
||||
{
|
||||
var primary = new FlipSwitchPrimary { FailNext = true };
|
||||
var ring = new RingBufferFallback(capacity: 16);
|
||||
var counter = Substitute.For<IAuditWriteFailureCounter>();
|
||||
|
||||
var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger<FallbackAuditWriter>.Instance);
|
||||
|
||||
var evt = NewEvent("doomed");
|
||||
// Must NOT throw — audit failures are always swallowed at this layer.
|
||||
await fallback.WriteAsync(evt);
|
||||
|
||||
Assert.Equal(1, ring.Count);
|
||||
counter.Received(1).Increment();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_PrimaryRecovers_RingDrains_InFIFOOrder_OnNextWrite()
|
||||
{
|
||||
var primary = new FlipSwitchPrimary { FailNext = true };
|
||||
var ring = new RingBufferFallback(capacity: 16);
|
||||
var counter = Substitute.For<IAuditWriteFailureCounter>();
|
||||
|
||||
var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger<FallbackAuditWriter>.Instance);
|
||||
|
||||
var failed = new[] { NewEvent("a"), NewEvent("b"), NewEvent("c") };
|
||||
foreach (var e in failed)
|
||||
{
|
||||
await fallback.WriteAsync(e);
|
||||
}
|
||||
|
||||
Assert.Equal(3, ring.Count);
|
||||
|
||||
// Primary recovers; the very next successful write should drain the
|
||||
// ring in FIFO order through the primary.
|
||||
primary.FailNext = false;
|
||||
var trigger = NewEvent("trigger");
|
||||
await fallback.WriteAsync(trigger);
|
||||
|
||||
Assert.Equal(0, ring.Count);
|
||||
// Order: the triggering event reaches the primary first (that's the
|
||||
// signal the primary has recovered), then the backlog drains in FIFO
|
||||
// submission order behind it.
|
||||
Assert.Equal(4, primary.Written.Count);
|
||||
Assert.Equal("trigger", primary.Written[0].Target);
|
||||
Assert.Equal("a", primary.Written[1].Target);
|
||||
Assert.Equal("b", primary.Written[2].Target);
|
||||
Assert.Equal("c", primary.Written[3].Target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_PrimaryAlwaysSucceeds_Ring_StaysEmpty()
|
||||
{
|
||||
var primary = new FlipSwitchPrimary();
|
||||
var ring = new RingBufferFallback(capacity: 16);
|
||||
var counter = Substitute.For<IAuditWriteFailureCounter>();
|
||||
|
||||
var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger<FallbackAuditWriter>.Instance);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await fallback.WriteAsync(NewEvent());
|
||||
}
|
||||
|
||||
Assert.Equal(0, ring.Count);
|
||||
Assert.Equal(10, primary.Written.Count);
|
||||
counter.DidNotReceive().Increment();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_FailureCounter_Incremented_Per_PrimaryFailure()
|
||||
{
|
||||
var primary = new FlipSwitchPrimary { FailNext = true };
|
||||
var ring = new RingBufferFallback(capacity: 16);
|
||||
var counter = Substitute.For<IAuditWriteFailureCounter>();
|
||||
|
||||
var fallback = new FallbackAuditWriter(primary, ring, counter, NullLogger<FallbackAuditWriter>.Instance);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await fallback.WriteAsync(NewEvent());
|
||||
}
|
||||
|
||||
counter.Received(5).Increment();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user