Rejection Strategies

In high-concurrency distributed systems, resources such as CPU, memory, and network bandwidth are finite. Whether you are working with thread pools, message queues, or RPC frameworks, there inevitably comes a moment when the incoming task rate exceeds the system's processing capacity. When a system reaches its saturation point or enters a shutdown state, it must have a predefined rule to handle new incoming requests. This mechanism is known as the Rejected Execution Policy.

In the context of Java's ThreadPoolExecutor, this logic is encapsulated by the RejectedExecutionHandler interface. The primary purpose of a rejection policy is not merely to "fail," but to provide a controlled way to manage system pressure, protect stability, or implement graceful degradation.
To understand when a rejection policy is triggered, one must understand the lifecycle of a task within a thread pool. A typical execution flow follows these steps:

  1. Core Thread Allocation: If the number of currently running threads is less than the corePoolSize, the executor creates a new thread to execute the task immediately.
  2. Queueing: If the core threads are all busy, the executor attempts to place the task into the work queue.
  3. Pool Expansion: If the work queue is full and the current number of threads is still less than the maximumPoolSize, the executor creates a new non-core thread to handle the task.
  4. Rejection: If the queue is full and the maximumPoolSize has been reached, the executor can no longer accept the task. At this point, the rejectedExecution method of the RejectedExecutionHandler is invoked.

Common Trigger Conditions

Rejection typically occurs under two primary scenarios:

  • Resource Saturation: The thread pool is active, but the work queue is at maximum capacity and all available threads (up to maximumPoolSize) are busy.
  • Lifecycle Termination: The thread pool has been shut down (via shutdown() or shutdownNow()), and a new task is submitted to a pool that is no longer accepting work.

Built-in Rejection Strategies

Java provides four standard implementations of the RejectedExecutionHandler to cover most common architectural needs.

1. AbortPolicy (The Fail-Fast Approach)

This is the default strategy. When a task is rejected, it immediately throws a RejectedExecutionException.

  • Best Use Case: Critical business logic where the caller must be explicitly notified of a failure so it can retry, log the error, or roll back a transaction.
  • Risk: If the calling code does not properly catch and handle this runtime exception, it could lead to unexpected application crashes.

2. CallerRunsPolicy (The Backpressure Approach)

Instead of throwing an exception, this policy forces the thread that attempted to submit the task (the "caller") to execute the task itself.

  • Best Use Case: Systems that require backpressure. By making the caller execute the task, the rate of new task submissions is naturally throttled, giving the thread pool time to catch up.
  • Risk: The calling thread (which might be an I/O thread or a UI thread) will be blocked until the task completes, potentially causing latency spikes in other parts of the application.

3. DiscardPolicy (The Silent Drop)

This policy simply drops the new task. No exception is thrown, and no notification is provided.

  • Best Use Case: Non-critical telemetry, such as logging, metrics collection, or certain types of monitoring data where losing a single data point is preferable to crashing the system.
  • Risk: It is "silent," meaning if you don't have external monitoring to track task rejection rates, you might not realize your system is underperforming or losing data.

4. DiscardOldestPolicy (The Freshness Approach)

This policy identifies the task at the head of the work queue (the oldest one), removes it, and then attempts to re-submit the current task.

  • Best Use Case: Real-time data processing where the most recent information is significantly more valuable than stale data (e.g., stock price updates or sensor readings).
  • Risk: It results in the loss of older tasks, which may be problematic if those tasks contain stateful or cumulative information.

Strategy Comparison Summary

Policy Behavior Primary Benefit Main Risk
AbortPolicy Throws RejectedExecutionException Immediate feedback/Fail-fast Unhandled exceptions
CallerRunsPolicy Caller thread executes the task Natural backpressure Blocks the submission thread
DiscardPolicy Silently drops the task Zero overhead for critical paths Hard to detect data loss
DiscardOldestPolicy Drops oldest task in queue Prioritizes fresh data Loss of historical/old tasks

Practical Implementation Example

The following code demonstrates how a rejection is triggered using the AbortPolicy. We create a constrained pool with a capacity of 1 to force a rejection quickly.

import java.util.concurrent.*;

public class RejectionDemo {
    public static void main(String[] args) {
        // Create a pool: 1 core thread, 1 max thread, queue capacity of 1
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
            1, 1, 0L, TimeUnit.MILLISECONDS,
            new ArrayBlockingQueue<>(1),
            new ThreadPoolExecutor.AbortPolicy()
        );

        for (int i = 1; i <= 3; i++) {
            final int taskId = i;
            try {
                executor.execute(() -> {
                    System.out.println("Executing Task " + taskId);
                    try {
                        Thread.sleep(1000); // Simulate work
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            } catch (RejectedExecutionException e) {
                System.err.println("Task " + taskId + " was rejected!");
            }
        }
        executor.shutdown();
    }
}

Execution Logic Breakdown:

  1. Task 1: Occupies the single available core thread.
  2. Task 2: Since the thread is busy, it is placed into the ArrayBlockingQueue (capacity 1).
  3. Task 3: The thread is busy, the queue is full, and the max pool size is reached. The AbortPolicy triggers, throwing a RejectedExecutionException.

Designing Custom Rejection Policies

In production-grade systems, built-in policies are often insufficient. A senior engineer will frequently implement a custom RejectedExecutionHandler to bridge the gap between "failing" and "recovering."

When implementing a custom handler, consider the following patterns:

  • Observability: Always log the rejection event and increment a metric counter (e.g., via Prometheus or Micrometer). Without metrics, you are flying blind during a system surge.
  • Persistence/Dead Letter Queues: For critical tasks, instead of discarding them, move them to a persistent store (like a database or a disk-based queue) to be reprocessed later.
  • Graceful Degradation: Return a "service busy" response to the user or switch to a simplified version of the logic that consumes fewer resources.
  • Blocking Submission: Implement a policy that forces the submitting thread to wait until space becomes available in the queue, effectively creating a manual backpressure mechanism.

By carefully selecting or designing your rejection policy, you transform a potential system failure into a managed, predictable component of your architecture's stability strategy.