using System.Threading.Channels; using ZB.MOM.WW.Audit; namespace ZB.MOM.WW.MxGateway.Server.Security.Audit; /// /// Bounded, non-blocking : enqueues onto a /// fixed-capacity channel and returns, leaving to batch the /// events onto the durable . /// /// /// The canonical contract is already best-effort — a failed audit /// write is swallowed rather than propagated. The channel makes the bound on that /// promise explicit: audit can cost the calling RPC at most one enqueue, never a SQLite /// round-trip, and at most events of memory. This matters on the /// constraint-denial path, where a partially denied bulk RPC previously awaited one insert per /// denied tag, serially, against the same database file the authentication hot path reads. /// /// When the channel is full the newest write is dropped () /// and counted in . Dropping is the deliberate choice over blocking: /// a stalled audit database must degrade audit completeness, not stall the gateway. Drops are /// logged, and reports the running total on its sweep. /// /// /// Until a drain attaches (), and again after it detaches, writes go /// straight through to . Enqueueing into a channel nobody will /// ever read would silently discard audit in the processes that have no hosted services — the /// apikey admin CLI and the DI-only tests — so those keep the original synchronous path. /// The same fallback covers a completed channel, so no combination of attach/detach can leave /// producers writing into a buffer that will never be read. /// /// public sealed class ChannelAuditWriter : IAuditWriter { /// /// Maximum number of audit events buffered before writes start being dropped. Sized to /// absorb a fully denied bulk RPC (the gateway's bulk request cap) plus headroom, so a /// realistic burst is buffered rather than lost. /// public const int ChannelCapacity = 4096; private readonly CanonicalAuditWriter _directWriter; private readonly ILogger _logger; private readonly Channel _channel; private long _droppedCount; private int _drainAttached; private int _dropLogged; /// Creates the writer and its bounded buffer. /// The synchronous writer used when no drain is attached. /// Logger for drop diagnostics. public ChannelAuditWriter(CanonicalAuditWriter directWriter, ILogger logger) { _directWriter = directWriter; _logger = logger; // DropWrite discards the incoming item and still reports success to the producer, so the // itemDropped callback is the only place a drop can be observed and counted. _channel = Channel.CreateBounded( new BoundedChannelOptions(ChannelCapacity) { FullMode = BoundedChannelFullMode.DropWrite, SingleReader = true, SingleWriter = false, }, itemDropped: RecordDrop); } /// Gets the number of audit events dropped because the channel was full. public long DroppedCount => Interlocked.Read(ref _droppedCount); /// Gets the reader the drain service consumes buffered events from. public ChannelReader Reader => _channel.Reader; /// /// Marks a drain as running, so subsequent writes enqueue instead of writing through. /// Called by once its one-time bootstrap has completed. /// public void AttachDrain() => Volatile.Write(ref _drainAttached, 1); /// /// Marks the drain as no longer running, so writes revert to the synchronous path. Called at /// shutdown, and whenever the drain loop dies, so late audit is still persisted rather than /// buffered into a channel with no reader. /// public void DetachDrain() => Volatile.Write(ref _drainAttached, 0); /// /// Enqueues a canonical audit event for the drain to persist. Never blocks and never throws. /// It also never touches the store on the caller's thread while a drain is attached — with one /// exception: once the channel has been completed (shutdown, or a drain loop that died), the /// enqueue fails and this falls through to the synchronous write, which is what keeps the event /// rather than stranding it in a buffer nobody reads. /// /// The canonical audit event to persist. /// Token honoured only by the direct write-through path. /// A task that represents the asynchronous operation. public Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(auditEvent); if (Volatile.Read(ref _drainAttached) == 0) { return _directWriter.WriteAsync(auditEvent, cancellationToken); } // Under DropWrite a full channel still reports SUCCESS — the discard surfaces through the // itemDropped callback. So a false here does not mean "full", it means the channel has // been completed and no drain will ever read it again (shutdown, or a re-attach onto a // dead channel). Writing through is the only outcome that keeps the event. if (!_channel.Writer.TryWrite(auditEvent)) { return _directWriter.WriteAsync(auditEvent, cancellationToken); } return Task.CompletedTask; } /// Signals that no further events will be enqueued, so the drain loop can finish. public void CompleteWriting() => _channel.Writer.TryComplete(); private void RecordDrop(AuditEvent auditEvent) { Interlocked.Increment(ref _droppedCount); // Log the first drop only; the running total is reported on the drain's periodic sweep, // so a sustained overload cannot turn audit pressure into a log flood. if (Interlocked.Exchange(ref _dropLogged, 1) == 0) { _logger.LogWarning( "Audit channel is full ({Capacity} events); dropping audit event {EventId} (action {Action}). " + "Audit is best-effort and bounded; further drops are reported in aggregate.", ChannelCapacity, auditEvent.EventId, auditEvent.Action); } } }