Files
scadalink-design/src/ScadaLink.AuditLog/Site/RingBufferFallback.cs
Joseph Doherty ff8766ec8b 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
2026-05-20 12:23:50 -04:00

116 lines
4.2 KiB
C#

using System.Runtime.CompilerServices;
using System.Threading.Channels;
using ScadaLink.Commons.Entities.Audit;
namespace ScadaLink.AuditLog.Site;
/// <summary>
/// Drop-oldest in-memory ring buffer used by <see cref="FallbackAuditWriter"/>
/// when the primary SQLite writer is throwing. Capacity is fixed at construction
/// (default 1024). When full, the oldest event is silently dropped to make room
/// for the newest — preserving the most recent picture of activity in the face
/// of an extended SQLite outage — and <see cref="RingBufferOverflowed"/> is
/// raised so a health counter can record the loss.
/// </summary>
/// <remarks>
/// <para>
/// Backed by a <see cref="Channel{T}"/> with
/// <see cref="BoundedChannelFullMode.DropOldest"/>. The channel doesn't natively
/// notify on drop, so this class compares <c>Reader.Count</c> before and after
/// each enqueue: any time we hit capacity and a subsequent enqueue keeps the
/// count at capacity, exactly one event has been dropped.
/// </para>
/// <para>
/// Per the M2 plan: the ring is the absolute-last-resort buffer for the
/// hot-path; it is NOT a substitute for the bounded
/// <see cref="SqliteAuditWriter"/> write queue.
/// </para>
/// </remarks>
public sealed class RingBufferFallback
{
private readonly Channel<AuditEvent> _channel;
private readonly int _capacity;
/// <summary>
/// Raised once each time a drop-oldest overflow occurs. Hooked by
/// <see cref="FallbackAuditWriter"/>'s health counter wiring.
/// </summary>
public event Action? RingBufferOverflowed;
public RingBufferFallback(int capacity = 1024)
{
if (capacity <= 0)
{
throw new ArgumentOutOfRangeException(nameof(capacity), "capacity must be > 0.");
}
_capacity = capacity;
_channel = Channel.CreateBounded<AuditEvent>(new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false,
});
}
/// <summary>Current event count in the ring (for diagnostics/tests).</summary>
public int Count => _channel.Reader.Count;
/// <summary>
/// Try to enqueue an event. Returns <see langword="true"/> on success (even
/// when an overflow caused an older event to be dropped); returns
/// <see langword="false"/> only when the ring has been
/// <see cref="Complete"/>-d.
/// </summary>
public bool TryEnqueue(AuditEvent evt)
{
ArgumentNullException.ThrowIfNull(evt);
// DropOldest TryWrite always succeeds unless the channel is completed.
// Detect overflow by comparing the count before vs. after: if we were
// already at capacity and remain at capacity, exactly one event was
// dropped to make room for evt.
var beforeCount = _channel.Reader.Count;
if (!_channel.Writer.TryWrite(evt))
{
return false;
}
if (beforeCount >= _capacity)
{
// The new event displaced an existing one.
RingBufferOverflowed?.Invoke();
}
return true;
}
/// <summary>
/// Drain the ring in FIFO order. Yields available events immediately and
/// then completes when the channel is empty AND <see cref="Complete"/> has
/// been called. Callers that only want to drain what's currently buffered
/// must call <see cref="Complete"/> first.
/// </summary>
public async IAsyncEnumerable<AuditEvent> DrainAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var evt in _channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
yield return evt;
}
}
/// <summary>
/// Non-blocking single-item dequeue used by the
/// <see cref="FallbackAuditWriter"/> recovery path. Returns
/// <see langword="false"/> when the ring is empty.
/// </summary>
public bool TryDequeue(out AuditEvent evt) => _channel.Reader.TryRead(out evt!);
/// <summary>
/// Mark the ring as no-more-writes. <see cref="DrainAsync"/> will yield the
/// remaining events and then complete.
/// </summary>
public void Complete() => _channel.Writer.TryComplete();
}