using System.Runtime.CompilerServices; using System.Threading.Channels; using ScadaLink.Commons.Entities.Audit; namespace ScadaLink.AuditLog.Site; /// /// Drop-oldest in-memory ring buffer used by /// 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 is /// raised so a health counter can record the loss. /// /// /// /// Backed by a with /// . The channel doesn't natively /// notify on drop, so this class compares Reader.Count 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. /// /// /// Per the M2 plan: the ring is the absolute-last-resort buffer for the /// hot-path; it is NOT a substitute for the bounded /// write queue. /// /// public sealed class RingBufferFallback { private readonly Channel _channel; private readonly int _capacity; /// /// Raised once each time a drop-oldest overflow occurs. Hooked by /// 's health counter wiring. /// 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(new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false, }); } /// Current event count in the ring (for diagnostics/tests). public int Count => _channel.Reader.Count; /// /// Try to enqueue an event. Returns on success (even /// when an overflow caused an older event to be dropped); returns /// only when the ring has been /// -d. /// 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; } /// /// Drain the ring in FIFO order. Yields available events immediately and /// then completes when the channel is empty AND has /// been called. Callers that only want to drain what's currently buffered /// must call first. /// public async IAsyncEnumerable DrainAsync( [EnumeratorCancellation] CancellationToken cancellationToken) { await foreach (var evt in _channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { yield return evt; } } /// /// Non-blocking single-item dequeue used by the /// recovery path. Returns /// when the ring is empty. /// public bool TryDequeue(out AuditEvent evt) => _channel.Reader.TryRead(out evt!); /// /// Mark the ring as no-more-writes. will yield the /// remaining events and then complete. /// public void Complete() => _channel.Writer.TryComplete(); }