Production incident

The queue that ate the heap

45 minsenior312 yrs

A real incident: symptom first, cause hidden, tradeoff at the end.

The incident

The import service accepts uploaded files and hands each one to a thread pool. It is configured to grow from 2 threads to 20 under load, with an AbortPolicy so that overload is refused rather than absorbed. During a large customer's migration: - latency climbed from 200ms to several minutes, then the pod was OOMKilled - the thread count never moved off 2, in a pool configured for 20 - not one request was ever rejected, in a pool configured to reject - the heap dump was dominated by one LinkedBlockingQueue holding the upload payloads Ops have asked to raise maximumPoolSize to 100. Before agreeing, work out whether that would have changed anything. Fix it, then answer ops — and state the honest cost of your fix, because it has one.

What this teaches

  • The order is: core threads, then the QUEUE, then growth to max, then reject
  • An unbounded queue makes maximumPoolSize and the rejection policy unreachable
  • Unreachable protection reads as protection in review, which is why it survives
  • Queue depth is latency already promised to someone who has probably timed out
  • Alert on queue depth; thread count is a lagging indicator that may never move

Starter

Starter.javaOpen in playground
import java.util.concurrent.*;

/**
 * Incident reproduction: the queue that ate the heap.
 *
 * The import service accepts uploaded files and hands each one to a thread
 * pool. It is configured to grow from 2 threads to 20 under load, with an
 * AbortPolicy so that overload is refused rather than absorbed.
 *
 * During a large customer's migration:
 *
 *   - latency climbed from 200ms to several minutes, then the pod was
 *     OOMKilled
 *   - the thread count never moved off 2, in a pool configured for 20
 *   - not one request was ever rejected, in a pool configured to reject
 *   - the heap dump was dominated by one LinkedBlockingQueue holding the
 *     upload payloads
 *
 * Ops asked to raise maximumPoolSize to 100. Before agreeing, work out
 * whether that would have changed anything.
 *
 * TASKS
 *   1. Run it. Confirm all three symptoms from one configuration line.
 *   2. Work out why maximumPoolSize never took effect. The answer is an
 *      ordering, not a bug.
 *   3. Fix it so the pool uses the threads it was given and refuses what it
 *      cannot handle.
 *   4. In a comment: answer ops. Would maximumPoolSize=100 have helped, and
 *      what is the honest cost of the fix you made instead?
 */
public class Starter {

    static final int TASKS = 200;
    static final int CORE = 2;
    static final int MAX = 20;

    /** What the pool is allowed to owe before we consider it overloaded. */
    static final int ACCEPTABLE_QUEUE_DEPTH = 25;

    public static void main(String[] args) throws Exception {
        /*
         * DEFECT: one line. A LinkedBlockingQueue built with no capacity
         * argument holds Integer.MAX_VALUE tasks.
         */
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                CORE, MAX,
                60, TimeUnit.SECONDS,
                new LinkedBlockingQueue<>(),
                new ThreadPoolExecutor.AbortPolicy());

        CountDownLatch release = new CountDownLatch(1);
        int refused = 0;

        for (int i = 0; i < TASKS; i++) {
            try {
                pool.execute(() -> {
                    try {
                        release.await();          // stands in for slow I/O
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            } catch (RejectedExecutionException e) {
                refused++;
            }
        }

        Thread.sleep(200);

        int threads = pool.getLargestPoolSize();
        int queued = pool.getQueue().size();

        System.out.println("── " + TASKS + " uploads, pool core=" + CORE + " max=" + MAX + " ──");
        System.out.println("  threads the pool actually made : " + threads + "   (configured for " + MAX + ")");
        System.out.println("  tasks waiting in the queue     : " + queued);
        System.out.println("  uploads refused                : " + refused);

        release.countDown();
        pool.shutdown();
        pool.awaitTermination(10, TimeUnit.SECONDS);

        boolean usedItsThreads = threads == MAX;
        boolean queueStayedBounded = queued <= ACCEPTABLE_QUEUE_DEPTH;
        boolean overloadWasSignalled = refused > 0;

        System.out.println();
        System.out.println("pool used the threads it was given : " + usedItsThreads);
        System.out.println("queue depth stayed bounded         : " + queueStayedBounded);
        System.out.println("overload was signalled, not hidden : " + overloadWasSignalled);
        System.out.println(
                usedItsThreads && queueStayedBounded && overloadWasSignalled ? "PASS" : "FAIL");
    }
}

Run it locally:

cd exercises/java/concurrency/executor-service/03-production
javac Starter.java -d /tmp/out && java -cp /tmp/out Starter

Hints

  1. Hint 1

    Three symptoms, one line. Which line is common to all three?

  2. Hint 2

    When does a ThreadPoolExecutor create a thread beyond corePoolSize? It is not 'when all threads are busy'.

  3. Hint 3

    Look at the queue's remainingCapacity() before submitting anything.

  4. Hint 4

    For ops: if the pool never grew past 2 with max=20, what does max=100 change?

Done when

  • The pool reaches its configured maximum thread count
  • Queue depth stays bounded
  • Overload is refused rather than queued
  • A comment answers whether maximumPoolSize=100 would have helped, with the reason
  • A comment states what the fix costs and why it is still the right trade

Solution

Show the solution — try it yourself first
Solution.javaOpen in playground
import java.util.concurrent.*;

/**
 * Solution: the queue that ate the heap.
 *
 * All three symptoms came from one line: the queue.
 *
 * A ThreadPoolExecutor uses its settings in an order most people guess wrong.
 * Core threads first, then the QUEUE, and only once the queue is full does it
 * create threads up to maximumPoolSize. Rejection is the step after that.
 *
 * `new LinkedBlockingQueue<>()` with no capacity argument holds
 * Integer.MAX_VALUE tasks, so the queue never filled. Which means:
 *
 *   the pool never grew past core        — step 3 was unreachable
 *   nothing was ever rejected            — step 4 was unreachable
 *   every upload was accepted and kept   — payload retained on the heap
 *
 * maximumPoolSize and the AbortPolicy were not misconfigured. They were
 * unreachable, which is worse, because both read as protection in review.
 *
 * THE ANSWER TO OPS
 *   maximumPoolSize=100 would have changed nothing at all. The pool was
 *   stuck at 2 because the queue never filled, and raising a limit that is
 *   never consulted is a no-op. Bounding the queue is what makes every other
 *   dial mean something.
 *
 * THE HONEST COST
 *   The service now visibly refuses uploads it previously accepted. That is
 *   not a regression: it previously accepted them and then failed to deliver
 *   them in time, holding each payload in memory while the client timed out.
 *   A refusal at submit time is a 503 the caller can retry; a queued task is
 *   latency already promised to someone who has usually stopped waiting.
 *
 *   The number to alert on is queue depth, not thread count. Thread count is
 *   a lagging indicator that, with an unbounded queue, never moves at all.
 */
public class Solution {

    static final int TASKS = 200;
    static final int CORE = 2;
    static final int MAX = 20;

    /** What the pool is allowed to owe before we consider it overloaded. */
    static final int ACCEPTABLE_QUEUE_DEPTH = 25;

    public static void main(String[] args) throws Exception {
        /*
         * FIX: bound the queue. Everything else was already correct and had
         * simply never been reached.
         *
         * The capacity is a real decision, not a default: it is how much
         * latency the service is willing to promise. 20 slots against 20
         * threads is roughly "one queued task per worker", which keeps the
         * debt visible and small.
         */
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                CORE, MAX,
                60, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(20),
                new ThreadPoolExecutor.AbortPolicy());

        CountDownLatch release = new CountDownLatch(1);
        int refused = 0;

        for (int i = 0; i < TASKS; i++) {
            try {
                pool.execute(() -> {
                    try {
                        release.await();          // stands in for slow I/O
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
            } catch (RejectedExecutionException e) {
                refused++;
            }
        }

        Thread.sleep(200);

        int threads = pool.getLargestPoolSize();
        int queued = pool.getQueue().size();

        System.out.println("── " + TASKS + " uploads, pool core=" + CORE + " max=" + MAX + " ──");
        System.out.println("  threads the pool actually made : " + threads + "   (configured for " + MAX + ")");
        System.out.println("  tasks waiting in the queue     : " + queued);
        System.out.println("  uploads refused                : " + refused);

        release.countDown();
        pool.shutdown();
        pool.awaitTermination(10, TimeUnit.SECONDS);

        boolean usedItsThreads = threads == MAX;
        boolean queueStayedBounded = queued <= ACCEPTABLE_QUEUE_DEPTH;
        boolean overloadWasSignalled = refused > 0;

        System.out.println();
        System.out.println("pool used the threads it was given : " + usedItsThreads);
        System.out.println("queue depth stayed bounded         : " + queueStayedBounded);
        System.out.println("overload was signalled, not hidden : " + overloadWasSignalled);
        System.out.println(
                usedItsThreads && queueStayedBounded && overloadWasSignalled ? "PASS" : "FAIL");
    }
}

Stretch

Swap AbortPolicy for CallerRunsPolicy and run it again. The refusals become work done on the submitting thread, which throttles the producer instead of failing it. Decide which you would ship for a file-upload endpoint, and name the situation where CallerRunsPolicy is actively dangerous — there is one, and it involves which thread is doing the submitting.

← Back to How do you size a thread pool, and what does an unbounded queue cost you?