Lessons Learned From Building a Ruby Gem API

Sucker Punch was created because I had a need for background processing without a separate worker. But I also figured others did too, given that adding a worker dyno on Heroku was $35. For hobby apps, this was a significant cost.

Having gotten familiar with Celluloid from my work on Sidekiq, I knew Celluloid had all the pieces to puzzle to make this easier. In fact, one of the earliest incarnations of Sucker Punch wasn’t a gem at all, just some Ruby classes implementing the pieces of Celluloid necessary to put together a background processing queue.

The resulting code was less than ideal. It worked, but didn’t feel like an API that anyone would want to use. From a beginner’s perspective, this would stop adoption in its tracks. This is a common challenge with any code we encounter. No doubt, the Ruby standard library has all the tools necessary to make just about anything we can dream of, but sometimes the result isn’t ideal. It’s the same reason libraries like Rspec and HTTParty can exist. Developers prefer to use simplistic DSLs over convoluted, similarly-functioning code. Ruby has always been a language where developers consistently tout their ability to write code that reads well, feeding the levels of developer happiness.

Why Rewrite Sucker Punch

It started when a version of Celluloid was yanked from RubyGems.org. This resulted in a flurry of tweets and GH issues detailing their inability to bundle their applications.

As of version 0.17, methods in public API changed without supporting documentation. On top of that, the core celluloid gem was split in to a series of child gems causing navigation to be painful.

This made my life as the Sucker Punch maintainer difficult. There were some requests to upgrade Sucker Punch to use Celluloid ~> 0.17 and I feared of what would happen if I did. This caused me to think about what the future of Sucker Punch looked like without Celluloid. I still use Sucker Punch and believe it’s a valuable asset to the community. My goal was to find a way to move it forward productively without experiencing similar pains.

In the end, thanks to some communinity contributions, Sucker Punch 1.6.0 was released with Celluloid 0.17.2 support.

Where to now?

Around that same time, Mike Perham had been writing about his experiences optimizing Sidekiq and whether continuing with Celluloid made sense for Sidekiq. Having less experience with multi-threading, it didn’t make sense for me to reinvent the wheel.

I had been hearing about concurrent-ruby through a variety of outlets, one of which was Rails replacing the existing concurrency latch with similar functionality from concurrent-ruby. After poking around concurrent-ruby, I realized it had all the tools necessary to build a background job processing library. Much like Celluloid in that respect, had the tools, but lacked the simple DSL for the use case.

What if Sucker Punch used concurrent-ruby in place of celluloid?

I can hear what you’re thinking…“What’s the difference? You’re swapping one dependency for another!”. 100% true. The difference was that the little bit of communication I had with the maintainers of concurrent-ruby felt comfortable, easy, and welcoming. And with concurrent-ruby now a dependency of Rails, it’s even more accessible for those using Sucker Punch within a Rails application (a common use case). But like before, there’s no way to be sure that concurrent-ruby won’t cause similar pains/frustrations.

Celluloid Basics

A basic Sucker Punch job looks like:

class LogJob
  include SuckerPunch::Job

  def perform(event)
    Log.new(event).track
  end
end

To run the job asynchronously, we use the following syntax:

LogJob.new.async.perform("new_user")

The most interesting part of this method chain is the async. Removing async, leaves us with a call to a regular instance method.

It so happens that async is a method in Celluloid that causes the next method to execute asynchronously. And this works because by including SuckerPunch::Job, we’re including Celluloid, which gives us the async method on instances of the job class.

Developing APIs

If you’re familiar with the basics of Celluloid, you’ll notice there’s not much to Sucker Punch. It adds the Celluloid functionality to job classes and does some things under the hood to ensure there’s one queue for each job class.

Early in my concurrent-ruby spike, I realized what a mistake to tie Sucker Punch’s API to the API of Celluloid. Tinkering with the idea of removing Celluloid has left Sucker Punch with two options:

  1. Continue using the async method with the new dependency
  2. Break the existing DSL and create a dependency-independent syntax and try my best to document and support the change through the backwards-incompatible change

Option 1 is the easy way out. Option 2 is more work, far more scary, but the right thing to do.

I decided to abandon my thoughts about previous versions and write as if it were new today. This will be the basis for the next major release of Sucker Punch (2.0.0).

Settling on abandoning the existing API, the next question is, “What should the new API look like?”.

Being a fan of Sidekiq, it didn’t take long for me to realize it could actually make developers lives easier if Sucker Punch’s API was the same.

Switching between Sidekiq and Sucker Punch is not uncommon. I look at Sidekiq as Sucker Punch’s big brother and often suggest people use it instead when the use case makes sense.

If you’re familiar with Sidekiq, using the perform_async class method should look familiar:

LogJob.peform_async("new_user")

So why not use the same for Sucker Punch?

If so, switching between Sidekiq and Sucker Punch would be no more than swapping include Sidekiq::Worker for include SuckerPunch::Job in the job class, aside from the gem installation itself. The result would be less context switching and more opportunity focus on the important parts of the application.

I can hear the same question again, “What’s the difference? You suggested isolating yourself from a dependency’s API and now you’re suggesting using another!”. I look at this one a little differently…

Sidekiq is uniquely positioned in the community as a paid open source project. We’re happy users of Sidekiq Pro and continue to do so for the support. You can certainly get support for the open source version, but one way to ensure Sidekiq is actively maintained is by paying for it. This financial support from us and others decreases the likelihood Mike will choose to abandon it. Mike’s also been public about his long-term interest in maintaining Sidekiq. With all this in mind, I’m willing to bank on its existence as the defacto way to enqueue jobs for background processing.

And if for some reason Sidekiq does disappear, there’s nothing lost on Sucker Punch. There’s no dependency. Just a similar syntax.

Sucker Punch 2.0.0 will have 2 class methods to enqueue jobs:

LogJob.perform_async("new_user")

and

LogJob.perform_in(5.minutes, "new_user")

The latter defining a delayed processing of the perform method 5 minutes from now.

Summary

Settling on a library’s API isn’t easy. Isolating it from underlying dependencies is the best bet for long-term stability. Using the adapter pattern can help create a layer (adapter) between your code and the dependency’s API. But like always, there are always exceptions.

I’m taking a leap of faith that doing what I believe is right won’t leave existing users frustrated, ultimately abandoning Sucker Punch altogether.

Sucker Punch v2.0 is shaping up to be the best release yet. I’m looking forward to sharing it with you.