ruby-concurrency/concurrent-ruby · error · Concurrent::RejectedExecutionError

Concurrent::RejectedExecutionError

Error message

Concurrent::RejectedExecutionError

What it means

ThreadPoolExecutor (and SingleThreadExecutor) use a bounded work queue; once the queue holds max_queue tasks and no new thread can be created, the executor runs its fallback action. With the default fallback_policy :abort that action raises Concurrent::RejectedExecutionError (a Concurrent::Error subclass, errors.rb:48) from the posting thread, as built at abstract_executor_service.rb:88. Posting to an executor that is shut down rejects the same way. This is Java-style explicit backpressure: the producer learns immediately that its task was not accepted.

Source

Thrown at lib/concurrent-ruby/concurrent/executor/abstract_executor_service.rb:88

    # @!macro executor_service_method_auto_terminate_setter
    def auto_terminate=(value)
      deprecated "Method #auto_terminate= has no effect. Set :auto_terminate option when executor is initialized."
    end

    private

    # Returns an action which executes the `fallback_policy` once the queue
    # size reaches `max_queue`. The reason for the indirection of an action
    # is so that the work can be deferred outside of synchronization.
    #
    # @param [Array] args the arguments to the task which is being handled.
    #
    # @!visibility private
    def fallback_action(*args)
      case fallback_policy
      when :abort
        lambda { raise RejectedExecutionError }
      when :discard
        lambda { false }
      when :caller_runs
        lambda {
          begin
            yield(*args)
          rescue => ex
            # let it fail
            log DEBUG, ex
          end
          true
        }
      else
        lambda { fail "Unknown fallback policy #{fallback_policy}" }
      end
    end

    def ns_execute(*args, &task)

View on GitHub (pinned to 0b88d5ff75)

Solutions

  1. Rescue Concurrent::RejectedExecutionError at the submit site and apply backpressure (retry with backoff, run inline, or drop).
  2. Raise max_queue (0 means unbounded on the pure-Ruby implementation) and/or raise max_threads to absorb bursts.
  3. Set fallback_policy: :caller_runs so the producer thread executes overflow work itself, or :discard to drop silently.
  4. Check executor.running? before posting and coordinate shutdown so late submitters fail in your code, not inside the pool.

Example fix

# before
pool = Concurrent::ThreadPoolExecutor.new(min_threads: 1, max_threads: 2, max_queue: 2, fallback_policy: :abort)
100.times { |i| pool.post { heavy(i) } } # 3rd pending task raises RejectedExecutionError

# after
pool = Concurrent::ThreadPoolExecutor.new(min_threads: 1, max_threads: 8, max_queue: 1_000, fallback_policy: :caller_runs)
100.times { |i| pool.post { heavy(i) } }
Defensive patterns

Strategy: try-catch

Validate before calling

def can_submit?(pool)
  return false unless pool.running?
  return true unless pool.respond_to?(:remaining_capacity)
  cap = pool.remaining_capacity # -1 means unbounded queue
  cap == -1 || cap.positive?
end

Try / catch

begin
  pool.post { work }
rescue Concurrent::RejectedExecutionError
  work.call # caller-runs fallback, or retry with backoff
end

Prevention

When it happens

Trigger: Concurrent::ThreadPoolExecutor.new(min_threads: 1, max_threads: 1, max_queue: 5, fallback_policy: :abort) then posting 6+ long-running blocks; a burst of submissions while all threads are busy; calling #post/#<< after #shutdown when the executor is no longer running.

Common situations: Sizing a bounded pool for a bursty job queue; migrating from the effectively unbounded global_io_executor to a strict custom pool; background or timeout paths that still submit tasks during shutdown; handler code posting to a shared bounded pool under load.

Related errors


AI-assisted analysis of ruby-concurrency/concurrent-ruby@0b88d5ff75 (2026-08-21). Data as JSON: /api/errors/747e1ef8776fba3c. Report an issue: GitHub.