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:
- Wait for incoming request
- Extract data from request
- 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:
- Stop saving each individual message, collect them in batches and periodically save them all at once
- 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 startingRack::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!