LogThrottle.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.entrystore.rest.springboot.util;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.LongSupplier;
/**
* Admits at most one occurrence per interval of an action that can fire once per request on an
* attacker-reachable path — a log line, which would otherwise turn the condition it reports into a
* log/disk amplifier, or an outbound retry. Lock-free and safe to call from latency-sensitive callbacks
* (e.g. Caffeine eviction listeners, which run inside the cache's eviction maintenance). The
* {@code AtomicLong} provides the visibility the current call sites need (the thread invoking the
* callback varies over time); the CAS additionally makes concurrent callers admit exactly one, as befits
* a shared util.
*/
public final class LogThrottle {
private final long intervalNanos;
private final LongSupplier nanoTime;
private final AtomicLong lastAdmittedNanos;
public LogThrottle(Duration interval) {
this(interval, System::nanoTime);
}
/**
* @param nanoTime monotonic nanosecond clock, e.g. a Caffeine {@code Ticker::read} so tests can advance it
*/
public LogThrottle(Duration interval, LongSupplier nanoTime) {
if (interval.isZero() || interval.isNegative()) {
throw new IllegalArgumentException("LogThrottle interval must be positive, got: " + interval);
}
this.intervalNanos = interval.toNanos();
this.nanoTime = nanoTime;
// Seed one interval in the past so the first occurrence is always admitted. (Seeding with
// Long.MIN_VALUE would overflow the subtraction below and suppress it instead.)
this.lastAdmittedNanos = new AtomicLong(nanoTime.getAsLong() - intervalNanos);
}
/**
* True at most once per interval; the caller acts only on true. This call CONSUMES the
* interval's single token when it returns true — evaluate it last in any condition, after every
* cheaper filter, or an admitted token is burned on an occurrence that is then not acted on.
* A rejected call does not extend the interval.
*/
public boolean tryAcquire() {
long now = nanoTime.getAsLong();
long last = lastAdmittedNanos.get();
if (now - last < intervalNanos) {
return false;
}
return lastAdmittedNanos.compareAndSet(last, now);
}
}