Class SpoolingAuditSink

java.lang.Object
com.mcpdbwizard.pub.SpoolingAuditSink
All Implemented Interfaces:
McpAuditSink

public final class SpoolingAuditSink extends Object implements McpAuditSink
Writes every audit record to disk first, then delivers it to a delegate sink and deletes it only once the delegate confirms.

This is what makes the trail survive an outage. A plain KafkaAuditSink loses records once the broker has been unreachable longer than its in-memory buffer absorbs; with a spool in front, those records are on disk and are replayed — including across a restart, because the spool is read back when the server starts.

Write-ahead, not fallback

Records are spooled before any delivery attempt, not after one fails. A fallback design cannot work here: McpAuditSink.record(com.mcpdbwizard.pub.McpAuditEvent) returns void and an asynchronous sink has not even attempted delivery by the time it returns, so there is nothing to fall back from. Writing first also means a process killed mid-call still has the record.

The guarantee, stated precisely

At-least-once, and duplicates are possible. A segment is deleted only after the delegate confirms via McpAuditSink.flush(), so a crash between delivering and deleting replays that segment. Every event carries McpAuditEvent.getId() so a consumer can collapse the repeat.

What survives what: with MCP_AUDIT_SPOOL_FSYNC=never (the default) records survive the process dying — a crash, a container restart, an OOM kill — because the bytes are in the operating system's cache. They do not survive the machine losing power. Setting always calls fsync per record, which survives that too and costs a disk round trip on every tool call.

Disk is not infinite. Past MCP_AUDIT_SPOOL_MAX_BYTES the policy decides: drop refuses new records and counts them, block makes the tool call wait for the drainer to catch up. Dropping the oldest is deliberately not offered — silently discarding the records already accepted for audit is the worst of the three. Copyright 2003-2026 ATB Consultancy Services Ltd (formerly Orinda Software Ltd, Dublin, Ireland)

  • Field Details

  • Constructor Details

    • SpoolingAuditSink

      public SpoolingAuditSink(McpAuditSink theDelegateValue, Path theDirectoryValue, long theMaxBytesValue, long theSegmentBytesValue, boolean theBlockFlagValue, boolean theFsyncFlagValue)
    • SpoolingAuditSink

      public SpoolingAuditSink(McpAuditSink theDelegateValue, Path theDirectoryValue, long theMaxBytesValue, long theSegmentBytesValue, boolean theBlockFlagValue, boolean theFsyncFlagValue, SpoolCipher theCipherValue)
      As above, with the cipher supplied rather than read from the environment.
      Parameters:
      theCipherValue - encrypts each spooled line, or null to write plaintext
  • Method Details

    • wrap

      public static McpAuditSink wrap(McpAuditSink theDelegateValue)
      Build from the environment, wrapping the given delegate. Returns the delegate unchanged when unset.
    • wrap

      public static McpAuditSink wrap(McpAuditSink theDelegateValue, String theSubdirectoryValue)
      As wrap(McpAuditSink), but placing the spool in a named subdirectory.

      A spool has exactly one writer by construction: one active file, and a drainer that takes every closed segment it finds. Two processes sharing a directory would therefore interleave writes into one file and each deliver the other's segments. A generated server is the only process in its JVM and needs no subdirectory; the web application, which records proxied requests alongside however many generated servers it has launched, passes one so that each spool has a single owner.

      Parameters:
      theSubdirectoryValue - a directory name to append, or null to use the configured directory as it stands
    • startDraining

      public void startDraining()
      Start the background drainer. Separate from the constructor so tests can drain deterministically.
    • record

      public void record(McpAuditEvent theEvent)
      Record one call. Must not throw.

      Appends to the spool. Never throws — a failure to audit must not become the caller's failure.

      Specified by:
      record in interface McpAuditSink
      Parameters:
      theEvent - the call to record; never null
    • drainOnce

      public long drainOnce()
      Deliver every closed segment, oldest first, deleting each only once the delegate confirms.
      Returns:
      how many records were delivered
    • getDroppedCount

      public long getDroppedCount()
      Records refused because the spool was full. Non-zero means the trail has holes.
      Specified by:
      getDroppedCount in interface McpAuditSink
    • getDeliveredCount

      public long getDeliveredCount()
      Records handed to the delegate and confirmed.
      Specified by:
      getDeliveredCount in interface McpAuditSink
    • getPendingCount

      public long getPendingCount()
      How many are still on disk awaiting delivery.
      Specified by:
      getPendingCount in interface McpAuditSink
    • describe

      public String describe()
      Names what the records finally go to, not just this wrapper.

      "SpoolingAuditSink" on its own answers the wrong question: the spool is how delivery survives an outage, while the destination is what an operator is checking.

      Specified by:
      describe in interface McpAuditSink
    • close

      public void close()
      Specified by:
      close in interface McpAuditSink