Lightweight multi-threaded Ruby application

We’re used to thinking of Ruby web applications being something slow, big and heavy like an elephant, but today let’s peek the opposite: the world of small pure Ruby web application capable of processing incoming data in the background.

Introduction

Consider a tiny Ruby application providing a simple REST API for collecting structured messages and saving them into a database. We will not be using Rails for it, we’re going to keep it as lightweight as possible. It will be based on Rack and use Puma as a web server. Let’s sketch the key points of its algorithm:

  1. Wait for incoming request
  2. Extract data from request
  3. Save data into database

The most time-consuming stage here is step #3. Performing database operation is a complex action involving establishing connection, sending data and waiting for a response (we’re using a database with REST API, so no connection pool is available).

There are two major things we could do to speed up step #3 and overall application performance:

  1. Stop saving each individual message, collect them in batches and periodically save them all at once
  2. Release listening worker as fast as possible by extracting data saving operations into the background thread

The problem

While these are very interesting problems by themselves, they are beyond the topic of this post. What I wanted to cover here is the way of starting up and, even more importantly, shutting down this kind of applications. Indeed, let’s suppose it collects batches of messages into an internal memory buffer and saves them into a database every 30 seconds. When termination signal arrives, web server just stops processing new requests and exits, but it knows nothing about our background message saving thread thus losing everything that resides into a buffer at the moment.

The solution

After doing some research on Puma internals I discoverd two things:

  • it introduces a Launcher class for the purpose of starting and stopping of web server worker
  • an instance of Launcher is available as a block’s parameter when creating and starting Rack::Server instance

This means we have to forget about configuring our Rack application with an ordinary config.ru file and encourage ourselves to get our hands dirty doing everything manually. Let’s compose a rack application startup file (I will name it config.rb) part by part.

“Require” your application

Firstly, we need to include our application code. It is pretty simple and may vary greatly:

require 'rubygems'
require 'bundler'

Bundler.require

require ::File.expand_path('../my_app_main_file', __FILE__)

Start background thread

THREAD_SLEEP_TIME = 5

@thr_stop = false
@thr = Thread.new do
  loop do
    stop_requested = @thr_stop

    if job_data_available?
      do_the_job
    end

    break if stop_requested
    sleep THREAD_SLEEP_TIME
  end
end

We created a new thread here and provided a way to stop it gracefully be setting the flag variable @thr_stop to true. Copying @thr_stop into local variable ensures the thread runs final job processing after the flag has been set in case some job data is still there.

Do not forget to syncronize access to job data beyween threads!

Create Rack application proc

As you know, Rack application is just a proc, taking environment as argument and returning a triplet of response code, response headers and response body (take a look at rack.github.io, if you need to refresh your memory):

app = Proc.new do |env|
  # Intense calculations involving setting data for background job
  ['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']]
end

Again, do not forget about syncronization when setting job data!

Optionally attach some middleware

I’d like to use Honeybadger for reporting application errors, so I’m adding their middleware:

require 'honeybadger'

Honeybadger.configure do |config|
  config.api_key = 'my_api_key'
  # config.env = 'production'
  # config.exceptions.ignore += [CustomError]
end

# And use Honeybadger's rack middleware
app = Honeybadger::Rack::ErrorNotifier.new(app)

Start the app or Metaprogramming magic

Finally, we are to start Puma server (make sure, it is installed!) providing its parameters as an options hash when creating Rack::Server instance. The start method allows you to provide your own block to it, which receives Launcher instance as its argument. And this is exactly what we were ravenous for! Now we can override launcher’s stop method, which is used internally to stop Puma server worker and append the event handling to it.

# Time to wait for thread to finish
THREAD_JOIN_WAIT_TIMEOUT = 10

# Configure Puma
options = {
  app: app,
  min_threads: 1,
  max_threads: 1,
  workers: 1,
  binds: 'tcp://0.0.0.0:9292'
}

Rack::Server.new(options).start do |launcher|
  launcher.define_singleton_method(:stop) do |*args|
    # Stop accepting new requests
    super(*args)
    # Signal background thread to stop
    @thr_stop = true
    # Wait for the thread to finish
    @thr.join(THREAD_JOIN_WAIT_TIMEOUT)
  end
end

Run, server, run!

The final touch on the picture is quite straightforward:

$ bundle exec ruby config.rb

Conclusion

We have just built the skeleton of an application, which is capable of processing incoming HTTP requests at the speed of light: listening worker spends time only for processing request data and putting it into a memory buffer, then it is ready to process next one. Memory consumption is extremely low also.

I hope you will not think of Ruby-based applications being slow and sluggish anymore. They can be anything you like, really!

Comment

Has something on your mind? Tweet it!