Class BlockedAttemptAudit

java.lang.Object
io.jenkins.plugins.batchcontrol.store.BlockedAttemptAudit

@Restricted(org.kohsuke.accmod.restrictions.NoExternalUse.class) public final class BlockedAttemptAudit extends Object
The single writer of the "an attempt was refused" audit records (ChangeType.MARKER_REUSE_BLOCKED, ChangeType.REMOTE_RUN_BLOCKED, ChangeType.TRIGGER_BLOCKED), with a bound on how often one repeated attempt may append (S-21).

Why a bound is needed. Both callers run inside queue scheduling, and core takes the global queue lock before it runs the Queue.QueueDecisionHandlers (hudson/model/Queue#schedule2: lock.lock() precedes the handler loop). A synchronous JSONL append therefore happens with the whole instance's scheduling blocked. An unbounded append on that path is a self-inflicted denial of service as soon as anything retries in a loop, and the ChangeType.REMOTE_RUN_BLOCKED caller makes that reachable over plain HTTP: a build-token holder can POST /job/X/build?token=… as fast as the network allows, and every one of those refusals wants a record.

Why coalescing rather than a cap. A global cap ("stop after N records") would silently drop new and different attempts once a noisy one had filled the budget, which is the opposite of what an audit trail is for. What repeats carries no new information, so this class keeps every distinct attempt and merges repeats of one: the first attempt under a key always writes, further attempts under the same key inside COOLDOWN do not. A merged attempt is still logged, so nothing becomes invisible — it just stops appending to a file under a global lock.

The tracking map is itself bounded (MAX_TRACKED_KEYS, least recently written key evicted first), so an actor cycling job names cannot grow it without limit; evicting a key only means the next attempt under it writes a record, which is the safe direction. The map is dropped when the Jenkins instance changes, so a restart starts from a clean slate rather than merging away the first post-restart attempt (the instance-scoped cache pattern security.GrantService already uses).

This class does not decide whether an attempt is worth recording, only how often the same one may be written; the callers own that judgement.

  • Method Details

    • swapStoreForTesting

      @Restricted(org.kohsuke.accmod.restrictions.NoExternalUse.class) public static Store swapStoreForTesting(Store replacement)
      Test seam (S-24-04): replaces the store this class appends to and returns the previous one, so a test can make appends fail and restore the real store afterwards. Not for production use.
    • get

      public static BlockedAttemptAudit get()
    • record

      public boolean record(ChangeType type, String attemptKey, String target, String user, String detail)
      Appends one refused-attempt record unless the same attempt by the same account was already recorded inside COOLDOWN.
      Parameters:
      type - the refusal type; one of the *_BLOCKED types
      attemptKey - what makes two attempts "the same" for merging purposes, chosen by the caller: everything that would make a second record say something new belongs in it (for a blocked marker re-use that is the request id as well as the job — two different approvals replayed on one job are two facts)
      target - the item the attempt was aimed at (the record's target)
      user - the account that made the attempt
      detail - the human-readable description stored on the record
      Returns:
      true if a record was appended, false if it was merged into an earlier one
    • record

      public boolean record(ChangeType type, String attemptKey, String target, String user, String detail, String grantId)
      As record(ChangeType, String, String, String, String), naming the grant involved (D-40: a refused CREATE of a name the grant's restriction does not allow).
    • recordCoalesced

      public boolean recordCoalesced(ChangeType type, String attemptKey, Duration cooldown, String target, String user, String detail)
      Appends one refused-attempt record unless one was already written under the same type and attemptKey inside cooldown, whoever made the attempt. Used for refusals where the account says nothing new: a blocked timer run is always SYSTEM, and a blocked upstream run is whatever the upstream build ran as, so a per-user key would neither shorten the history nor add information.
      Parameters:
      cooldown - how long a written key merges further attempts
      Returns:
      true if a record was appended, false if it was merged
    • recordPersonRefusal

      public boolean recordPersonRefusal(ChangeType type, String attemptKey, String target, String user, String build, String detail)
      D-51, D-51a: appends the record of a re-run a person submitted and was refused. A repeat of the same attempt (attemptKey) by the same user within COOLDOWN is merged. Per user at most PERSON_BUDGET such records are written per rolling PERSON_WINDOW; the next refusal in that window writes one summary record, and the ones after it are counted. The store is append-only (JSONL), so the count is written as a closing record when the summary's window ends (flushPersonSummaries() on the per-minute work, the user's own next refusal, or flushAtShutdown()); until then each counted refusal is logged at INFO.

      Both maps are bounded at MAX_PERSON_KEYS. Evicting an attempt key only lets that attempt write again, which still spends the user's budget. A user's budget is evicted only once it is idle (no write inside the window, no open summary); when the map is full of users still inside their window, a new user is counted under one shared overflow budget, so no budget is ever reopened by eviction.

      The bookkeeping is constant-time per call (S-22-04): the only other work is closing the caller's own expired summary, at most one more append. Other users' summaries are closed and idle budgets removed by flushPersonSummaries() on the per-minute work, so the queue lock this runs under (see the class comment) is held no longer than the hand-off to the store (D-51a, S-21-05).

      Parameters:
      build - the re-run build's number as text ("" when unknown), named in a summary
      Returns:
      true if a record (per-attempt or summary) was appended
    • flushPersonSummaries

      public void flushPersonSummaries()
      The per-minute work (S-22-03, S-22-04): collects, under this object's lock, the closing count records of every summary whose window has ended and removes idle budgets, then appends the records outside the lock, so a queue thread refusing something meanwhile never waits behind these appends. Never runs on the queue lock.
    • flushAtShutdown

      @Terminator public static void flushAtShutdown()
      S-22-03: writes the closing count record of every open summary before Jenkins stops, so no summary is left promising a count that never follows. A crash still loses an open count (the records are append-only; documented).