🔴 The Error You're Seeing
Confirm this matches your console output. If it does, you're in the right place.
java.util.concurrent.RejectedExecutionException: Task com.devinhyderabad.jobs.ReportJob$$Lambda$57/0x0000000801046ac8@5674cd4d rejected from java.util.concurrent.ThreadPoolExecutor@23fc625e[Running, pool size = 1, active threads = 1, queued tasks = 1, completed tasks = 0]
at java.base/java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2032)
at java.base/java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:787)
at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1328)
at com.devinhyderabad.api.UploadController.handleUpload(UploadController.java:44)⚡ Quick Fix Works 80% of the time
Read the brackets: active = max and queued = capacity prove true saturation. Either shed load deliberately (CallerRunsPolicy) or grow capacity with math, not hope.
int cores = Runtime.getRuntime().availableProcessors();
ThreadPoolExecutor pool = new ThreadPoolExecutor(
cores, cores, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(cores * 50), // sized, not guessed
new ThreadPoolExecutor.CallerRunsPolicy()); // throttle producer🧠 Why this Happens
Tap to expand the deep technical explanation
execute() runs a three-step ladder: hand to an idle worker, start a worker if below maximum, else offer to the queue. With all workers active (active threads = pool size) and the bounded queue at capacity, the offer fails and control reaches the handler. AbortPolicy throws with both snapshots embedded — this is measured backpressure telling you arrival rate exceeds service rate at current sizing.
The HITEC City Parking Spot Analogy:
A car wash with every bay occupied and the waiting lane full: new drivers are turned away at the entrance sign rather than parked nose-to-tail down the highway.
🔁 How to Reproduce Confirm this is your error
Build ThreadPoolExecutor(1,1,0,MS,new ArrayBlockingQueue<>(1)), submit one sleeping task plus two more — the third submit throws with [Running, pool size = 1, active threads = 1, queued tasks = 1]. (Lab capture: OpenJDK Temurin 25.0.2.)
🛠️ Solutions (5 Ways to Fix)
Size the pool and queue from measured demand
👉 Use this when/if defaults were copied and nobody computed capacity.
Apply Little’s Law: steady-state backlog equals arrival rate times latency, so required queue depth is arrivals-per-second times worst-case task seconds, times safety margin. CPU-bound pools target core count; IO-bound pools size on blocking ratio. Documenting the arithmetic turns the limit from mystery into contract.
// e.g. 50 uploads/sec x 0.2s avg = 10 in flight; queue 200 = 20x headroom
new ThreadPoolExecutor(8, 8, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadPoolExecutor.AbortPolicy());Throttle producers with CallerRunsPolicy
👉 Use this when/if the submitter can afford to run the task itself.
Instead of throwing, the caller executes the task inline. Request threads slow to task speed, arrivals naturally pace down, and nothing is dropped — genuine backpressure. Caveat: on HTTP request threads this propagates slowness to clients, which is either the point or a problem depending on your SLOs.
new ThreadPoolExecutor(4, 4, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(64),
new ThreadPoolExecutor.CallerRunsPolicy());Spill overflow to a durable store
👉 Use this when/if no task may be lost and bursts are legitimate.
A custom handler serializes the rejected task into Kafka, a database table, or disk, increments a rejection metric, and a scheduled consumer drains the spill when the pool recovers. Load spikes become delayed processing instead of failed requests.
ThreadPoolExecutor ingest = new ThreadPoolExecutor(8, 8,
60L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(500),
(r, executor) -> { // RejectedExecutionHandler
spill.save((ReportJob) r); // durable side channel
rejectedMeter.increment(); // alert threshold on this
});Bulkhead: separate pools per dependency class
👉 Use this when/if one slow sink starves unrelated work sharing the pool.
When report generation and image resizing share one saturated pool, both features stall together. Partitioning into small dedicated pools caps blast radius: the image pool saturating leaves reports untouched, and each pool’s queue depth becomes a meaningful health signal.
import static java.util.concurrent.TimeUnit.MILLISECONDS;
ExecutorService reports =
new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(50));
ExecutorService thumbnails =
new ThreadPoolExecutor(4, 4, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(200));
// one saturated bulkhead no longer poisons the otherDEV ONLY: swap in an unbounded LinkedBlockingQueue
👉 Use this never beyond a lab — it deletes the symptom and keeps the overload.
Executors.newFixedThreadPool does exactly this internally: the queue grows without bound, rejection vanishes, and memory climbs until OutOfMemoryError arrives hours later, decoupled from the request that caused it. Saturation signals belong in metrics, not suppressed.
// ANTI-PATTERN — do not ship
new ThreadPoolExecutor(4, 4, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>()); // unbounded: OOME deferred, not fixed📋 Version Notes
The [Running, ...] snapshot already exists — verified in jdk7u and jdk8u ThreadPoolExecutor.toString(); saturation reads straight from the exception string.
Format unchanged; identical diagnostics in every rejection message.
Virtual-thread-per-task executors shift the question upstream: they never reject for queue depth, so rate-limit callers explicitly instead.
🛡️ How to Prevent This Next Time
Export queue size, active count, and rejection counters as metrics with alert thresholds, load-test at twice expected peak to find the cliff early, and review any pool whose queue capacity is unset or unexplained in code comments.