Reconfiguring your application live with dRuby

Paweł Świątkowski - Jan 10 - - Dev Community

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/...
Enter fullscreen mode Exit fullscreen mode

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.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .