Reconfiguring your application live with dRuby
by Paweł Świątkowski
08 Jan 2024
dRuby is a pretty old but relatively unknown part of Ruby standard distribution. I first wrote about it here in 2018 and I have to admit that to this day I haven really found a production use case for it. However, I still think it a gem worth knowing, even if only to impress you Ruby friends on a conference afterparty.
To demonstrate what dRuby can do, we will write a simple application. It will periodically check Mastodon API of ruby.social server and check for new messages (called toots). To keep things as simple as possible, we’ll just use net/http
as an HTTP client. Here’s our first draft:
require "net/http"
require "json"
class RubySocialChecker
ENDPOINT = "https://ruby.social/api/v1/timelines/public?local=true"
def call
response = Net::HTTP.get(URI(ENDPOINT + "&limit=1"))
parsed = JSON.parse(response)
@last_toot_id = parsed.first["id"]
run_loop
end
def run_loop
loop do
response = Net::HTTP.get(URI(ENDPOINT + "&min_id=#{@last_toot_id}"))
JSON.parse(response).each do |toot|
puts toot['uri']
@last_toot_id = toot['id']
end
sleep(5)
end
end
end
RubySocialChecker.new.call
If you run it and you’re lucky (i.e. someone posts something), you will see links to new toots being printed to stdout. As you see, the code is not particularly complicated. So let’s complicate it with seemengly no good reason. In the next step we will extract a configuration to a separate class:
class Config
attr_accessor :interval, :debug
def initialize(interval: 5, debug: false)
@interval = interval
@debug = debug
end
end
class RubySocialChecker
ENDPOINT = "https://ruby.social/api/v1/timelines/public?local=true"
def initialize(config = Config.new)
@config = config
end
def call
response = Net::HTTP.get(URI(ENDPOINT + "&limit=1"))
parsed = JSON.parse(response)
@last_toot_id = parsed.first["id"]
run_loop
end
def run_loop
loop do
response = Net::HTTP.get(URI(ENDPOINT + "&min_id=#{@last_toot_id}"))
parsed = JSON.parse(response)
if @config.debug
puts "[#{Time.now}] Fetched #{parsed.size} toots"
end
JSON.parse(response).each do |toot|
puts toot['uri']
@last_toot_id = toot['id']
end
sleep(@config.interval)
end
end
end
Hmm… This starts to look serious! We now have a config, which we pass to the checker. The config specifies how often we should check for new toots and also has a flag for a debug mode. In this mode we output a message with how many toot we just fetched, so you can at least see that something is happening.
We can run our checker in a debug mode now:
config = Config.new(debug: true)
RubySocialChecker.new(config).call
Okay, but why did we do that? Because now we want to add dRuby. This gem essentially allows you to “hook into” your running Ruby program from another process in a controlled manner. Let’s add dRuby server to our program right before the code starting the checker.
require "drb/drb"
uri = "druby://localhost:8787"
config = Config.new
DRb.start_service(uri, config)
RubySocialChecker.new(config).call
Now, run the program (note that the debug mode is off) and now in a different terminal window fire up IRB. In the IRB session, do the following:
irb(main):001> require "drb/drb"
=> true
irb(main):002> DRb.start_service
=> #<DRb::DRbServer:0x00007f60d2da7868 ...>
irb(main):003> config = DRbObject.new_with_uri("druby://localhost:8787")
=> #<DRb::DRbObject:0x00007f60d27d3d10 @ref=nil, @uri="druby://localhost:8...
irb(main):004> config.debug = true
=> true
If you now look at the terminal where your program is running… Magic! The debug messages started to show. Now let’s spice the things up a bit:
irb(main):005> config.interval = 1
=> 1
The logs show up even faster. We haven’t touched anything in the running program, it does not read from any database on each loop step, but we managed to alter its behaviour from the outside. It’s also worth noting that the IRB process does not know anything about the checker or config. If you try to reference them, you’ll see the uninitialized constant error.
irb(main):009> RubySocialChecker
(irb):9:in `<main>': uninitialized constant RubySocialChecker (NameError)
from /home/katafrakt/.asdf/...
irb(main):010> Config
(irb):10:in `<main>': uninitialized constant Config (NameError)
Did you mean? RbConfig
from /home/katafrakt/.asdf/...
See this in action:
Ok, but why?
Like I said, you probably won’t benefit from it in your Rails application. Web applications are stateless by nature and here you need some in-memory state to hook into. However, there are some cases, mostly long-running processes, where this can be useful. The first time I’ve seen a magic like that, although it was not in Ruby, was an IRC bot, in which admin was able to turn some features on and off live, add people to denylist etc., all without restarting the application.
It might also be an alternative to logs. If you have, for example, a scraper that scrapes thousands of pages, instead of log results every 100 of them, you can expose an interface over dRuby to ask how many pages you checked, how many had useful results and even return these results.
But even if you don’t do any of these things, it’s good to know that doing things like that is possible and you don’t even have to install any additional gem to do that.
You can read more about dRuby in the docs. Or you can even buy a book about it.