Optimization: Fix the memory heavy ruby operation

chunks = orders.each_slice(50_000).to_a

can be quite memory-heavy, especially if orders is already a large Array. each_slice itself is lazy-ish through an Enumerator, but .to_a eagerly materializes all chunks and all their references.

More importantly, if orders came from:

orders = Order.where(...).pluck(:amount)

you’ve already loaded the entire result set into memory before chunking it.

The better approach

Don’t build all chunks. Keep only the amount of data necessary for the workers currently running.

For example, instead of:

orders = Order.where(...).pluck(:amount)

chunks = orders.each_slice(50_000).to_a

ractors = chunks.map do |chunk|
  Ractor.new(chunk) do |values|
    expensive_calculation(values)
  end
end

use a bounded pipeline:

orders = Order.where(...).pluck(:amount)

results = []

orders.each_slice(50_000) do |chunk|
  ractor = Ractor.new(chunk) do |values|
    expensive_calculation(values)
  end

  results << ractor.value
end

But notice something important: this doesn’t actually give you parallelism. You’re creating one Ractor and immediately waiting for it.

The interesting solution is bounded concurrency:

MAX_RACTORS = 4
results = []

orders.each_slice(50_000).each_slice(MAX_RACTORS) do |batch|
  ractors = batch.map do |chunk|
    Ractor.new(chunk) do |values|
      expensive_calculation(values)
    end
  end

  results.concat(ractors.map(&:value))
end

Now you’re only holding roughly four chunks plus their Ractors at a time:

Database result
50k chunk
50k chunk
50k chunk
50k chunk
┌─────────────┐
│ 4 Ractors │
└─────────────┘
results
next 4 chunks

However, there is an even bigger improvement for Rails.

Don’t pluck everything first

This:

orders = Order.where(...).pluck(:amount)

loads the entire result into memory.

For a million orders:

Database
│ 1,000,000 amounts
Ruby Array
memory

Instead, stream records in batches:

Order
.where(created_at: 30.days.ago..)
.in_batches(of: 50_000) do |relation|
amounts = relation.pluck(:amount)
# process this batch
end

Now the architecture becomes:

                 Database
                    │
             50,000 rows
                    │
                    ▼
              Ruby memory
                    │
          ┌─────────┼─────────┐
          ▼         ▼         ▼
       Ractor    Ractor    Ractor
          │         │         │
          └─────────┼─────────┘
                    ▼
                 result
                    │
                    ▼
              next 50k rows

This is much healthier because the database itself becomes your natural source of batching.

And you can make the Ractor pool bounded:

BATCH_SIZE = 10_000

Order.where(created_at: 30.days.ago..)
     .in_batches(of: BATCH_SIZE) do |relation|

  ractors = []

  values = relation.pluck(:amount)

    ractors << Ractor.new do |values|
      values.sum { |value| value ** 3 }
    end
end
ractors.each(&:value)

One more important point

For our Ractor/Rails article, change the example we used earlier.

This:

orders = Order.where(...).pluck(:amount)
chunks = orders.each_slice(50_000).to_a

teaches the wrong optimization pattern because it creates a potentially enormous in-memory dataset before parallelization.

A senior-level Rails example should demonstrate:

Database batching
bounded number of chunks
bounded number of Ractors
aggregate results
release memory
next database batch

That gives you bounded memory + bounded parallelism, which is a much better production architecture.

Also, there’s an important subtlety with Ractors: passing data between Ractors can itself have copying/memory costs, so “smaller batches” aren’t automatically better. The correct batch size needs to be benchmarked against CPU cost, memory pressure and Ractor communication overhead.