Martian Chronicles
Evil Martians’ team blog
Back-end

AnyCable: Action Cable on steroids

Action Cable is one of the most attractive new features in Rails 5. WebSockets out of the box—sounds pretty cool, doesn’t it?

Action Cable shows a plenty of good parts:

  • Astonishingly easy configuration;
  • Application logic access through channels—definitely the killer feature;
  • JavaScript client that works with no additional effort.

Everything you need to build yet another Chat on Rails in minutes!

Now, to the troubling part. In my opinion, besides bugs, the only problem is Ruby itself—the language I’m fond of, but not considering it a technology fit for writing scalable concurrent applications. Not yet, at least (yeah, everyone heard about Ruby 3x3 and Matz’s plans).

Benchmarks: Action Cable vs. Erlang vs. Golang

So what exactly is wrong with Action Cable? The answer is performance.

Let’s consider several metrics: memory usage, CPU pressure, and broadcasting time. We built our benchmarks on top of the famous WebSocket Shootout by HashRocket.

We compared Action Cable with servers written in Erlang and Golang. The hardware we used to run the benchmarks was AWS EC2 4.xlarge (16 vCPU, 30Gib memory). Let’s take a look at the results.

First, the RAM usage:

Action Cable memory usage

Benchmark: handle 20 thousand idle clients to the server (no subscriptions, no transmissions).

The results are pretty self-explaining: Action Cable requires much more memory. Because it’s Ruby. And, of course, Rails.

For the next two measurements we used WebSocket Shootout broadcasting benchmark which can be described as follows:

1000 clients connect, 40 of them send a message to a server, which is then re-transmitted to all clients. The sender-client measures the time it takes the message to make a round-trip (RTT).

Look how the CPU reacts during the benchmark:

Action Cable CPU usage

And, last but not least, the broadcasting performance:

Action Cable RTT

About a second for one thousand clients and more than ten seconds for ten thousand! Doesn’t look real-time, does it?

But can we use the good parts of Action Cable with the power of Erlang/Golang/Whatever-you-like altogether? The answer is “Yes, we can”. And here comes AnyCable.

The Idea

When @dhh announced Action Cable on April 2015, my initial thought was “Wow, this is Omakase I like!” The second thought was: “Oh, no, Ruby wasn’t meant for that.” And finally, the third thought: “I guess I know how to fix this!”

I’ve already had experience writing WebSocket-based applications in Erlang, and I knew that Erlang is one of the best players in this game, so I wrote down a simple architecture sketch:

AnyCable sketch

Looks pretty obvious except one thing: the communication between Erlang server and Rails app (Action Cable channels). The straightforward solution is using HTTP and REST. But don’t forget that we want to improve performance, so that’s not an option.

But aren’t we able to develop our own RPC protocol with raw TCP transport and binary encoding, maybe, Protocol Buffers? We sure are!

Luckily, I found a better option—gRPC. The puzzle was solved and AnyCable was born.

Introducing AnyCable

Take a look at the figure below:

AnyCable architecture

AnyCable architecture.

AnyCable WebSocket server is responsible for handling clients, or sockets. That includes:

  • low-level connections (sockets) management;
  • subscriptions management;
  • broadcasting messages to clients.

The server doesn’t know anything about your business logic. It’s a kind of a logicless proxy server, just like NGINX for HTTP traffic.

WebSocket server should include gRPC client built from AnyCable rpc.proto, that describes the following RPC service:

service RPC {
  rpc Connect (ConnectionRequest) returns (ConnectionResponse) {}
  rpc Subscribe (CommandMessage) returns (CommandResponse) {}
  rpc Command (CommandMessage) returns (CommandResponse) {}
}

RPC server is a connector between Rails application and WebSocket server. It’s an instance of your application with a gRPC endpoint which implements rpc.proto.

This server is a part of the anycable gem. This gem also makes it possible to use all Action Cable infrastructure (channels, streams) without tweaks (see below for more information).

We use Redis pub/sub to send messages from the application to the WS server, which then broadcasts the messages to clients.

We’re discussing other options for such communication, ‘cause we don’t want to require any specific dependencies (even such popular technology as Redis). Feel free to join us!

How AnyCable works?

After a brief look at the AnyCable architecture let’s go deeper and see what’s going on—from both a Rails application and a client points of view.

Consider a simple example—yet another Action Cable chat application. We want to implement the following features:

  • Only authenticated users can join rooms;
  • User can join the specific chat room (using ID);
  • User can send a message to everyone in the room.

Each feature requires client-server communication. Let’s discuss these one by one.

Authentication

So how does authentication happen in Action Cable? The same way we usually handle it in our controllers: with cookies.

Every time a client connects to Action Cable the application instantiates an instance of ApplicationCable::Connection class and invokes #connected method on it. The instance is added to the list of all connections and stays in memory until the client disconnects.

The connection allows you to access the underlying request object and, of course, its headers, including cookies:

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connected
      self.current_user = User.find_by(id: cookies[:user_id])
      reject_unauthorized_connection unless current_user
    end
  end
end

What about AnyCable? The connection itself occurs outside of the Rails application, on our WebSocket server. How can we authenticate it, or, more precisely, how to invoke our #connected method?

This is how AnyCable WebSocket server handles a client connection:

  • accepts connection;
  • invokes Connect RPC method and pass the request headers (some of them, e.g. Cookies) to it;
  • receives a response from an RPC server describing connection identifiers (JSON encoded) and status (diconnected or not?);
  • disconnects the client if necessary;
  • transmits messages to the client if the response object contains any.

And that is what happens within Connect method realization on our RPC server:

  • create instance of slightly patched ApplicationCable::Connection class;
  • invoke #handle_open on this connection;
  • reply with the connection information (identifiers) and status.

Note that AnyCable doesn’t store any objects (e.g. connections) in memory. They’re all disposable.

Subscriptions

Ok, now we’re connected to the server. To make this connection useful we have to subscribe to a channel.

To do so, the client has to send a message in a specific form – {"command": "subscribe", "identifier": "..."}.

Consider our example chat channel code:

class ChatChannel < ApplicationCable::Channel
  def subscribed
    stream_from "chat_#{params[:id]}"
  end
end

The client sends the message (JS code):

App.cable.subscriptions.create({channel: 'ChatChannel', id: 1}, ...)

AnyCable server transforms this message into a Subscribe RPC call and then:

  • adds the client (socket) to the chat_1 broadcasting group
  • sends a subscription confirmation to the client.

What’s a broadcasting group? Implementations may vary and depend on a language or platform you choose to build your WebSocket server. But to one degree or another it quacks like a hash that maps streams to connections.

Performing Actions

We’re almost done. The only question remaining is, how do we send a message to the clients?

Let’s add one more method to our channel:

class ChatChannel < ApplicationCable::Channel
  ...

  def speak(data)
    ActionCable.server.broadcast(
      "chat_#{params[:id]}",
      text: data['text'], user_id: current_user.id
    )
  end
end

To perform an action the client should send another command – {"command": "message", "identifier": "..."}. We can handle it the same way we do with the “subscribe” command.

The interesting thing here is the #broadcast call. Why? Because it’s the weakest part of Action Cable (see broadcasting benchmark above).

In a simplified form, broadcasting can be implemented like this:

subscribers_map[stream_id].each do |channel|
  channel.transmit message
end

What’s wrong with it? First, Rails itself. The second suspect is Ruby.

AnyCable moves this functionality out of Ruby and Rails and leverages the power of other technologies.

I think, it’s time to repeat our benchmarks with the new kid on the block – AnyCable.

Benchmarks: AnyCable

AnyCable shows almost the same resources usage as raw Erlang or Golang servers.

Memory usage is more than acceptable:

AnyCable memory usage

Benchmark: handle 20 thousand idle clients to the server (no subscriptions, no transmissions).

And CPU doesn’t feel any pressure too:

AnyCable CPU usage

Broadcasting time is perfect:

Action Cable RTT

Compatibility

AnyCable aims to be as much compatible with Action Cable, as it possible to use standard Action Cable in development and test environments. But some features are rather hard to implement without loss of the performance gain.

See compatibility table for further information.

How to use?

Want to give AnyCable a try? It’s pretty easy:

# we recommend to only use AnyCable in production
# to reduce the complexity of development setup
gem "anycable-rails" #, group: :production
  • Generate and run RPC server:
rails g anycable:install

./bin/anycable
  • Install and run compatible WebSocket server, e.g. anycable-go:
anycable-go -addr='localhost:3334'
  • Don’t forget to configure Action Cable URL:
# config/production.rb or config/application.rb
config.action_cable.url = "ws://localhost:3334/cable"

Here is an example Procfile for Foreman or Hivemind:

web: bundle exec rails s
rpc: bundle exec ./bin/anycable
go:  anycable-go -addr='localhost:3334'

Do not have Rails 5 application with Action Cable yet? Feel free to play with our demo application!

What’s next?

We’ve learned one aspect of AnyCable – the plug and play performance upgrade for Action Cable.

This approach – extracting low-level functionality from business logic or separation of concerns – can help us solve many problems:

  • support transport fallbacks (e.g. long-polling) or custom transports;
  • build shareable WebSocket servers (i.e. one WebSocket server for several different applications);
  • add enhanced analytics and monitoring to your WebSocket server;
  • and even use Action Cable (as protocol) outside of Rails.

And, of course, you can easily extend your cable with custom features, that are not supported (and unlikely to be) by Action Cable.


AnyCable is a fast-growing open-source project, which includes several libraries in different languages. We are currently on alpha stage and have a plan to make it beta in a couple of months (follow us to stay in tune).

We are open for questions (use our Gitter) and contributions (check out AnyCable projects on GitHub).