ruby-concurrency/concurrent-ruby · error · ArgumentError

an executor must be provided

Error message

an executor must be provided

What it means

Concurrent.dataflow_with / dataflow_with! build a Future whose block runs once the input IVars resolve, scheduled on the executor you pass as the first argument. call_dataflow rejects a nil executor immediately, because scheduling on nothing would otherwise surface later as an obscure failure inside the Future machinery.

Source

Thrown at lib/concurrent-ruby/concurrent/dataflow.rb:57

  def dataflow_with(executor, *inputs, &block)
    call_dataflow(:value, executor, *inputs, &block)
  end
  module_function :dataflow_with

  def dataflow!(*inputs, &block)
    dataflow_with!(Concurrent.global_io_executor, *inputs, &block)
  end
  module_function :dataflow!

  def dataflow_with!(executor, *inputs, &block)
    call_dataflow(:value!, executor, *inputs, &block)
  end
  module_function :dataflow_with!

  private

  def call_dataflow(method, executor, *inputs, &block)
    raise ArgumentError.new('an executor must be provided') if executor.nil?
    raise ArgumentError.new('no block given') unless block_given?
    unless inputs.all? { |input| input.is_a? IVar }
      raise ArgumentError.new("Not all dependencies are IVars.\nDependencies: #{ inputs.inspect }")
    end

    result = Future.new(executor: executor) do
      values = inputs.map { |input| input.send(method) }
      block.call(*values)
    end

    if inputs.empty?
      result.execute
    else
      counter = DependencyCounter.new(inputs.size) { result.execute }

      inputs.each do |input|
        input.add_observer counter
      end

View on GitHub (pinned to 0b88d5ff75)

Solutions

  1. Pass a real executor: `Concurrent.dataflow_with!(Concurrent.global_io_executor, f1, f2) { |a, b| a + b }` or your own ThreadPoolExecutor.
  2. Default nil away: `Concurrent.dataflow_with!(executor || Concurrent.global_io_executor, *inputs, &job)`.
  3. If no custom executor is needed, use plain `Concurrent.dataflow` / `Concurrent.dataflow!`, which run on the global IO executor.
  4. Note the sibling checks enforced in the same method: a block is required and every input must be an IVar (e.g. a Future or another dataflow result).

Example fix

// before
executor = config[:pool]        # nil when key missing
Concurrent.dataflow_with!(executor, f1, f2) { |a, b| a + b }

// after
executor = config.fetch(:pool) { Concurrent.global_io_executor }
Concurrent.dataflow_with!(executor, f1, f2) { |a, b| a + b }
Defensive patterns

Strategy: validation

Validate before calling

executor = Concurrent.global_io_executor if executor.nil?
Concurrent.dataflow_with!(executor, *inputs, &job)

Type guard

def executor_like?(e)
  !e.nil? && e.respond_to?(:post)
end

Try / catch

begin
  Concurrent.dataflow_with!(executor, f1, f2, &job)
rescue ArgumentError => e
  raise unless e.message == 'an executor must be provided'
  Concurrent.dataflow_with!(Concurrent.global_io_executor, f1, f2, &job)
end

Prevention

When it happens

Trigger: `Concurrent.dataflow_with!(nil, f1, f2) { |a, b| a + b }`; an executor variable that is nil because configuration was missing or the assigning branch never ran; explicitly passing nil as a placeholder for the executor slot.

Common situations: Executor selection from config/YAML where the key is absent; custom thread pools created conditionally; refactoring between dataflow (which uses Concurrent.global_io_executor) and dataflow_with! and dropping the argument.

Related errors


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