Instrumenting and Scaling the Action Cable worker pool
Action Cable (aka ActionCable or action_cable) is a mature, useful stack of tech that allows easily integrating two-way communication in a Rails app via websockets.
There seems to be a complete lack of discussion on the web about instrumenting or scaling it. Here are my findings and experiments to that end so far.
The one piece of configuration that is offered is worker_pool_size. The docs don't offer much info. The code has a bit more.
What is this pool? A bare bones Concurrent::ThreadPoolExecutor. config.action_cable.worker_pool_size
sets ThreadPoolExecutor
's max_threads
. min_threads
is hardcoded to 1.
Here are docs on when ThreadPoolExecutor
scales the pool up and down. It scales up when "the queue is full" (not sure if this means, queue has things and threads are busy, or if it's absolutely full. The phrasing suggests the latter, but that seems like an unlikely design). It scales threads down when they are "idle for too long".
How long is too long? The default, which can't be changed via Action Cable config, is 60 seconds.
In my experiments (more on that below), I did not see the threads actually going down. So when does ThreadPoolExecutor
consider if a thread is too old? Only when doing more work.
So, if the pool's workload is bursty, such as doing a batch of 50 tasks every 2 minutes and then sitting idle, it will never scale down. The method for thread pruning is private, but we can invoke it with meta-programming.
Using ThreadPoolExecutor
instance methods, we can observe the size of the pool and work queue. We don't have direct access to these in the context of Action Cable but can get to them pretty straightforwardly with some meta-programming.
ThreadPoolExecutor
doesn't let us change the timeout or the minimum pool size after instantiation, but we can achieve this with meta-programming as well.
Bringing it all together, here is some code to observe and control the Action Cable pool. I did this in dev in order to understand the overall system behavior. This isn't a recommendation for what to put into production.
puma.rb
on_worker_boot do
# if you don't want/need rufus, you can achieve something similar with an
# adhoc Thread and sleep. note that on_worker_boot blocks puma startup,
# so it does need to go into a thread
require 'rufus-scheduler'
scheduler = Rufus::Scheduler.new
def scheduler.on_error(job, error)
Raygun.track_exception(error)
end
executor = ActionCable.server.worker_pool.instance_variable_get('@executor')
executor.instance_variable_set('@idletime', 5)
executor.instance_variable_set('@min_length', 0)
scheduler.every '1 second' do
Rails.logger.info "ActionCable pool idletime " + executor.idletime.to_s
Rails.logger.info "ActionCable pool threads in pool" + executor.length.to_s
Rails.logger.info "ActionCable pool tasks waiting" + executor.queue_length.to_s
Rails.logger.info "ActionCable pool prune #{executor.send(:ns_prune_pool)}"
# https://github.com/puma/puma/issues/1512
Rails.logger.info "DB Pool: #{ActiveRecord::Base.connection_pool.stat}"
end
end
config/database.yml
so that the db pool responds quickly to changing needs. n.b. i think the actual reaping only happens every 60 seconds
idle_timeout: 5
config/initializers/actioncable_instrumentation.rb
You could do something like this to time and count the work that Action Cable is doing (MyTimer
and MyStats
are pseudocode, replace with your own tools).
module ActionCableServerWorkerWithInstrumentation
def async_invoke(*args)
MyTimer.time('ActionCable Worker task time') do
super
end
MyStats.increment('ActionCable Worker tasks')
end
end
module ActionCable
module Server
class Worker
prepend ActionCableServerWorkerWithInstrumentation
end
end
end
Now WEB_CONCURRENCY=1 rails s