WorkerCircuitBreaker.java
package org.egothor.methodatlas.discovery.typescript;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Tracks consecutive worker-process restarts and trips an open circuit when
* the restart rate exceeds safe operating limits.
*
* <h2>Policy</h2>
*
* <p>
* The circuit opens when the number of worker restarts within the trailing
* {@code windowSeconds} sliding window reaches or exceeds
* {@code maxRestarts}. Once open, the circuit never closes — the plugin is
* disabled for the remainder of the scan run. Operators can increase the
* threshold via the {@code typescript.maxConsecutiveRestarts} and
* {@code typescript.restartWindowSec} configuration properties.
* </p>
*
* <h2>Rationale</h2>
*
* <p>
* Without a circuit breaker, a buggy worker or a systematically
* parse-error-inducing input file would cause infinite restart loops,
* consuming system resources and masking the root cause. The circuit breaker
* bounds the impact: after a configurable number of failures it emits a clear
* {@code WARNING} and stops attempting recovery.
* </p>
*
* <h2>Thread safety</h2>
*
* <p>
* All mutating methods are guarded by a {@link ReentrantLock}. The circuit
* breaker is shared across the worker pool, which may service requests from a
* single thread but could in future be extended to multiple scan threads.
* </p>
*/
final class WorkerCircuitBreaker {
private static final Logger LOG = Logger.getLogger(WorkerCircuitBreaker.class.getName());
private final int maxRestarts;
private final Duration window;
private final Clock clock;
/** Guards all mutable state in this class. */
private final ReentrantLock lock = new ReentrantLock();
/** Timestamps of recent restart events within the sliding window. */
private final Deque<Instant> restartTimestamps = new ArrayDeque<>();
/** Whether the circuit is currently open (plugin disabled). */
private boolean open;
/**
* Creates a circuit breaker with the given limits.
*
* @param maxRestarts maximum number of restarts allowed within the window
* before the circuit opens; must be positive
* @param windowSeconds width of the sliding time window in seconds;
* must be positive
* @throws IllegalArgumentException if either argument is not positive
*/
/* default */ WorkerCircuitBreaker(int maxRestarts, int windowSeconds) {
this(maxRestarts, windowSeconds, Clock.systemUTC());
}
/**
* Creates a circuit breaker with a custom clock for testing.
*
* @param maxRestarts maximum restarts within the window
* @param windowSeconds sliding window width in seconds
* @param clock clock used to determine current time
*/
/* default */ WorkerCircuitBreaker(int maxRestarts, int windowSeconds, Clock clock) {
if (maxRestarts <= 0) {
throw new IllegalArgumentException("maxRestarts must be positive, got: " + maxRestarts);
}
if (windowSeconds <= 0) {
throw new IllegalArgumentException("windowSeconds must be positive, got: " + windowSeconds);
}
this.maxRestarts = maxRestarts;
this.window = Duration.ofSeconds(windowSeconds);
this.clock = clock;
}
/**
* Returns {@code true} when the circuit is open (plugin must be disabled).
*
* @return {@code true} if the circuit has tripped
*/
/* default */ boolean isOpen() {
lock.lock();
try {
return open;
} finally {
lock.unlock();
}
}
/**
* Records a worker restart event and opens the circuit if the restart
* threshold has been exceeded within the sliding window.
*
* <p>
* When the circuit trips, a {@code WARNING} log line is emitted once.
* Subsequent calls to {@link #recordRestart()} are silently ignored.
* </p>
*/
/* default */ void recordRestart() {
lock.lock();
try {
if (open) {
return; // already open — no further action needed
}
Instant now = clock.instant();
Instant windowStart = now.minus(window);
// Evict restart records that have fallen outside the window.
while (!restartTimestamps.isEmpty() && restartTimestamps.peekFirst().isBefore(windowStart)) {
restartTimestamps.pollFirst();
}
restartTimestamps.addLast(now);
if (restartTimestamps.size() >= maxRestarts) {
open = true;
if (LOG.isLoggable(Level.WARNING)) {
LOG.warning("TypeScript worker pool circuit breaker TRIPPED — "
+ restartTimestamps.size() + " restarts within the last "
+ window.toSeconds() + " s (limit=" + maxRestarts + "). "
+ "TypeScript scanning is disabled for the remainder of this run. "
+ "Investigate worker logs for the root cause; "
+ "increase typescript.maxConsecutiveRestarts or "
+ "typescript.restartWindowSec to raise the threshold.");
}
}
} finally {
lock.unlock();
}
}
/**
* Returns the number of restart events currently recorded within the
* sliding window. Useful for monitoring and testing.
*
* @return restart count within the active window
*/
/* default */ int restartsInWindow() {
lock.lock();
try {
if (open) {
return restartTimestamps.size();
}
Instant now = clock.instant();
Instant windowStart = now.minus(window);
// Count without evicting (read-only view).
return (int) restartTimestamps.stream()
.filter(t -> !t.isBefore(windowStart))
.count();
} finally {
lock.unlock();
}
}
}