ruby 80 lines · 3 tabs

Parallelize Independent External Calls (in a bounded way)

Shared by codesnips Jan 2026
3 tabs
module BoundedFanOut
  Result = Struct.new(:value, :error) do
    def ok?
      error.nil?
    end
  end

  def self.map(items, size: 4)
    queue = Queue.new
    items.each_with_index { |item, i| queue << [i, item] }

    results = Array.new(items.size)
    mutex = Mutex.new

    workers = Array.new([size, items.size].min) do
      Thread.new do
        loop do
          index, item = queue.pop(true)
          value = yield(item)
          mutex.synchronize { results[index] = Result.new(value, nil) }
        rescue ThreadError
          break # queue empty
        rescue => e
          mutex.synchronize { results[index] = Result.new(nil, e) }
        end
      end
    end

    workers.each(&:join)
    results
  end
end
3 files · ruby Explain with highlit

When a request needs data from several independent upstream services, calling them one after another wastes time: total latency becomes the sum of every call. Because these calls do not depend on one another, they can run concurrently, turning the total latency into roughly the slowest single call. The catch is that unbounded concurrency is dangerous — spinning up a thread per item can exhaust file descriptors, overwhelm a downstream service, or trip its rate limiter. The pattern shown here fans out work across a fixed-size pool so parallelism is capped at a safe level.

The BoundedFanOut tab implements the pool using Queue, which is thread-safe, as the work channel. It enqueues each item alongside its original index, then starts exactly size worker threads. Each worker loops, popping items with a non-blocking pop(true) until the queue raises ThreadError (empty), so no sentinel values are needed. Results are written into a pre-sized results array at the item's index, preserving input order regardless of completion order. A Mutex guards the shared array. Exceptions are captured rather than raised, so one failing call cannot silently kill the whole batch; the caller decides what to do.

The EnrichmentService tab is the real-world consumer. It gathers a profile, credit score, and recent orders for a user — three unrelated HTTP calls — and passes them as lambdas to BoundedFanOut.map. Each lambda wraps its call in Timeout.timeout so a hung upstream cannot block a worker forever, and the results are matched back to their keys by position. Note that IO-bound work like HTTP benefits from threads even on MRI, because the GIL is released during blocking IO.

The trade-offs matter. A fixed pool bounds resource usage and gives a predictable ceiling on load sent to any dependency, but it is not a substitute for per-host rate limiting or circuit breaking. Capturing exceptions means partial success is possible, so callers must inspect results. Timeout.timeout is blunt and can interrupt at awkward points, so it is best reserved for genuinely external, idempotent reads. This approach fits read-heavy aggregation endpoints where several slow, independent lookups would otherwise serialize.


Related snips

Share this code

Here's the card — post it anywhere.

Parallelize Independent External Calls (in a bounded way) — share card
Link copied