feat(auditlog): RingBufferFallback with drop-oldest overflow (#23)

Adds RingBufferFallback — an in-memory drop-oldest ring buffer used by
the upcoming FallbackAuditWriter (Bundle B-T4) when the primary SQLite
writer is throwing. Backed by Channel<AuditEvent> with
BoundedChannelFullMode.DropOldest, fixed capacity (default 1024).

Channel.CreateBounded(DropOldest) does NOT natively signal a drop on
TryWrite, so overflow is detected by comparing Reader.Count before and
after the enqueue: when the buffer is already at capacity and a new
TryWrite succeeds while keeping the count at capacity, exactly one
event was displaced and RingBufferOverflowed is raised (one event per
drop).

Public surface:
- bool TryEnqueue(AuditEvent) — always succeeds unless completed.
- IAsyncEnumerable<AuditEvent> DrainAsync(CancellationToken) — FIFO.
- void Complete() — closes the channel so DrainAsync can finish.
- event Action? RingBufferOverflowed — health counter hook.

Tests (3 new, total 23 -> 26):
- Enqueue_1025_Into_1024Cap_Ring_DropsOldest_AndRaisesOverflowOnce
- DrainAsync_Yields_FIFO_Then_Completes_When_Empty
- TryEnqueue_AllSucceeds_ReturnsTrue
This commit is contained in:
Joseph Doherty
2026-05-20 12:20:55 -04:00
parent 01480c6ea2
commit 55fbcce7a8
2 changed files with 199 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
using ScadaLink.AuditLog.Site;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Types.Enums;
namespace ScadaLink.AuditLog.Tests.Site;
/// <summary>
/// Bundle B (M2-T3) tests for <see cref="RingBufferFallback"/> — the
/// drop-oldest fallback used by <see cref="FallbackAuditWriter"/> when the
/// primary SQLite writer is throwing.
/// </summary>
public class RingBufferFallbackTests
{
private static AuditEvent NewEvent(string? target = null)
{
return new AuditEvent
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTime.UtcNow,
Channel = AuditChannel.ApiOutbound,
Kind = AuditKind.ApiCall,
Status = AuditStatus.Delivered,
Target = target,
PayloadTruncated = false,
ForwardState = AuditForwardState.Pending,
};
}
[Fact]
public async Task Enqueue_1025_Into_1024Cap_Ring_DropsOldest_AndRaisesOverflowOnce()
{
var ring = new RingBufferFallback(capacity: 1024);
var overflowCount = 0;
ring.RingBufferOverflowed += () => Interlocked.Increment(ref overflowCount);
var events = Enumerable.Range(0, 1025).Select(i => NewEvent(target: i.ToString())).ToList();
foreach (var e in events)
{
Assert.True(ring.TryEnqueue(e));
}
Assert.Equal(1, overflowCount);
// The surviving 1024 are events[1..1024] (oldest dropped).
var drained = new List<AuditEvent>();
ring.Complete();
await foreach (var e in ring.DrainAsync(CancellationToken.None))
{
drained.Add(e);
}
Assert.Equal(1024, drained.Count);
Assert.Equal("1", drained[0].Target);
Assert.Equal("1024", drained[^1].Target);
}
[Fact]
public async Task DrainAsync_Yields_FIFO_Then_Completes_When_Empty()
{
var ring = new RingBufferFallback(capacity: 16);
var enqueued = Enumerable.Range(0, 5).Select(i => NewEvent(target: i.ToString())).ToList();
foreach (var e in enqueued)
{
Assert.True(ring.TryEnqueue(e));
}
ring.Complete();
var drained = new List<AuditEvent>();
await foreach (var e in ring.DrainAsync(CancellationToken.None))
{
drained.Add(e);
}
Assert.Equal(5, drained.Count);
for (int i = 0; i < 5; i++)
{
Assert.Equal(i.ToString(), drained[i].Target);
}
}
[Fact]
public void TryEnqueue_AllSucceeds_ReturnsTrue()
{
var ring = new RingBufferFallback(capacity: 16);
for (int i = 0; i < 8; i++)
{
Assert.True(ring.TryEnqueue(NewEvent()));
}
}
}