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);
}
}
}