Русская версия: От Action к Any

Internal aspects you may notice while migrating from ActionCable to AnyCable

What’s inside:

  • How to count online users in ActionCable and why it would break in AnyCable
  • Small differences between current_users defined by identified_by in Connection::Base class.
  • Why AnyCable will break your devise authentication and how to fix it.
  • What’s the difference between stop_all_streams call in AnyCable and ActionCable
  • How to stop connection from the outside with ActionCable, and what to do in AnyCable.
  • What happens with cross-origin security in AnyCable comparing to ActionCable.

This little memo will be useful if you want to dig a little deeper into understanding ActionCable and AnyCable realizations compared to each other.

Pay attention described issues are actual only for next gem versions: anycable-rails 0.5.2, anycable 0.5.0 and actioncable 5.1.4! This may change in a nearest perspective.

Introduction

Just look below for real measurements done by Vladimir Dementyev creater of AnyCable and see yourself.

Benchmark: handle 20 thousand idle clients to the server (no subscriptions, no transmissions). from: ActionCable on steroids
ActionCable VS Anycable running WebSocket Shootout benchmark. ( from ActionCable on steroids )
broadcast perfomance in seconds per connections count ( from ActionCable on steroids )

So now you have at least three strong reasons to switch from ActionCable to AnyCable. Let’s see if there is any reason against such change.

Here is a nice compatibility table from the AnyCable repo:

But except for this table, there are couple nuances, which you may notice while migrating toward AnyCable. Let’s start with a simple: front-end.

Front-end

Back-end

Anycable VS AtionCable architecture. Source: anycable-rails repo
Closer look to AnyCables architecture. Source: ActionCable on steroids

First look just at big puctures gives as short list of changes: websocket server now lives by itself and ActionCable is replaced with AnyCable RPC based on gRPC.

Minus Rack middleware

The most valuable example of such critical middleware is the Warden middleware and devise gem which depends on it. Here is an example of StackOverflow hint on devise usage with ActionCable:

def find_verified_user 
# this checks whether a user is authenticated with devise
if verified_user = env['warden'].user
verified_user
else
reject_unauthorized_connection
end
end

In AnyCable it’s completly useless since there is no middleware and env['warden'] always equals nil.

Here is an example on how you can emulate intended behaviour in AnyCable:

Connection and Channel methods: from active to passive

One more change follows ws-server separation aside from Rails codebase and transforming Rack-based to gRPC: it’s migration from active to passive behaviour of some Connections/Channels methods. In ActionCable they actually did some action, for example making calls to redis and so. In AnyCable many of them just preparing RPC answer.

Like, for example, a stop_all_streams method. In ActionCable it would go directly to redis, and in AnyCable it just raises some flag and until you return results to ws-server — nothing would happen.

How this knowledge may come handy? In two ways actually: when an exception raised after stop_all_streams call ActionCable streams would be stopped anyway, and in AnyCable they would not, and in case of some misbehaviour at a junction of AnyCable and ws-server is good to know were different things are actually done.

Long live Connection!

actioncable/lib/action_cable/channel/base.rb

Channel instances are long-lived. A channel object will be instantiated when the cable consumer becomes a subscriber, and then lives until the consumer disconnects.

Long-lived channels (and connections) also mean you’re responsible for ensuring that the data is fresh. If you hold a reference to a user record, but the name is changed while that reference is held, you may be sending stale data if you don’t take precautions to avoid it.

Now we may notice the next difference in an AnyCable realization: there are no long-lived objects inside AnyCable! All objects get instantinated only at a time of RPC call through globalid gem serialization/deserialization process.

It means that all ActiveRecord objects relayed to connection through idetified_by call, now will be reloaded before use, i.e. current_user in AnyCable is equal to current_user.reload in ActionCable.

In one way it’s suitable because all objects now always in an actual state. But this has a slight drawback — now ActiveRecord objects used as Channel identifiers may additionally hit DB.

Enjoy the silence…

Every connection object not only controls a user channel subscription but also subscribed on an internal channel, which is used when there is a need to finish this connection.

Lets run usual demo-app, from an ActionCable guide, connect to it from two different browsers, subscribe on two different channels and take a look whats inside redis.pubsub:

2.4.1 :020 > redis.pubsub('channels')=> ["action_cable/Z2lkOi8vYWN0aW9uY2FibGUtZXhhbXBsZXMvVXNlci8x", 
"messages:1:comments",
"messages:2:comments", "action_cable/Z2lkOi8vYWN0aW9uY2FibGUtZXhhbXBsZXMvVXNlci8z"]

Channels starts with a ‘messages’ prefix are chat rooms channels from the demo-app code, and channels with a ‘action_cable/’ prefix are the internal channels one per each connection, one for each browser ( not a tab ).

Now lets run AnyCable example and look inside Redis pubsub: there is only one message queue, by default its called __anycable__!

What can we derive from that? First of all, a total cables throughput is limited to a throughput of a one redis pubsub queue. Second is that many StackOverflow hints which were hits now are misses, for example: How do I find out who is connected to ActionCable? or ActionCable — how to display number of connected users?

Back to the begining of this topic, lets look inside a RemoteConnections to review how things are done in a remote disconnecting:

ActionCable.server
.remote_connections
.where(current_user: User.find(1))
.disconnect

and in a disconnect method:

server.broadcast internal_channel, type: "disconnect"

So as you can see ActionCable sends a disconnect message through an internal channel. But AnyCable doesn’t have internal channels! It means that you surely can broadcast a disconnect message, but it will silently go nowhere.

As a compatibility table states:

Disconnect remote clients : Nope

Can we do this in AnyCable appllication?

Without changing ws-server, there is no reliable way to disconnect selected user from a Rails application code. You can do this through customized front-end Channel class, which may try to drop a connection after recieving a proper message. If JS ignore by some reasons your disconnect message, than you’ll have to wait till your favorite ws-server realization gets updated or at least restarted.

Couple words about config

You can read about an AnyCable configuration in anycable-rails repo.

Main difference:

  • AnyCable doesn’t use cable.yml for any of its configuration, because AnyCable doesn’t use adapters as abstraction and also doesn’t alow different pubsub subscription source except for redis pubsub*.
  • AnyCable settings runned by anyway_config gem, so they can be configured in different ways, through config/anycable.yml, or ENV, or even through Anycable.configure call.

About settings syncronization between ws-server and RPC/rails code you can find in generated default config available in anycable-rails since version 0.5.2.

*This is valid for current version of anycable-rails ( 0.5.2 ), more pubsub sources are in discussions and plans.

Cross Site WebSocket Hijacking (CSWSH).

When you are starting a ws-server supported by AnyCable you may introduce a set of headers wich would be delivered by ws-server to an application, like this:

-headers=cookie,x-api-token,origin

As you can see, you can allow cookies for a session determination and an origin header for connection validation. But right now in anycable-rails 0.5.2 AnyCable RPC lost process method call nand default check allow_request_origin? inside of it:

# ActionCable::Connection::Base
# Called by the server
when a new WebSocket connection is established. This configures the callbacks intended for overwriting by the user.
# This method should not be called directly -- instead rely upon on the #
connect (and #disconnect) callbacks.
def process #:nodoc:
logger.info started_request_message
if websocket.possible? && allow_request_origin?
respond_to_successful_request
else
respond_to_invalid_request
end
end

You need to deliver origin validation by yourself in your application Connection class, using original ActionCable implementation of allow_request_origin? and a proper config:

config.action_cable.allowed_request_origins = ['http://rubyonrails.com', %r{http://ruby.*}]

Or you can move validation to your reverse proxy.

Conclusion

Many thanks to Vladimir Dementyev (AnyCables author) for gem and comments to this article.

Chief Software Architect / CTO, Ruby and PostgresSQL fan.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store