From Action to Any

Русская версия: От 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

For those who lately wake up from cryosleep and missed everything: ActionCable is a part of Ruby on rails framework delivering realtime web functionality based on WebSocket. AnyCable is high perfomance polyglot alternative to ActionCable with the aim of a maximum compatibility and the lowest possible code changes during its adaptation in place of ActionCable. Does it actually tradeoff or pure win?

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

Good news everyone: nothing changed at the front-end! AnyCable is fully compartible from the front-end perspective with ActionCable.

Back-end

This is a little bit more complicated:

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

This schema is already enough to make the first suggestion on what is already missing in AnyСable: despite a Rails application loading, AnyCable is an RPC service, and ActionCable is a Rack-based application. That means all Rack-middlewares are unavailable and don’t work out of the box with AnyCable! And if you need any of its functionality you should build workarounds manually.

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 of 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-base 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.

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!

In an application based on ActionCable ActionCable::Connection instances live through all connection time, not only Channels object but Connection objects also.

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…

Lets review InternalChannel and RemoteConnections classes ( they both placed in ActionCable::Connection namespace ).

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

How to configure ActionCable you can read in an official ActionCable guide. Shortly: pubsub adapter configured in config/cable.yml, and the rest through config.action_cable inside application.rb or environment files.

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).

Finally lets say a little about WebSocket security. If you are using secured sockets the only security threat, you left with is the Cross Site WebSocket Hijacking (CSWSH). It can be easily prevented by a proper origin check inside either your reverse proxy or your application.

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

I liked AnyCable and its features, all the little differences I discover while incorporating it I’d rather consider as small distinctions not the flaws. But I would not recommend to use them both together but in different environment, ActionCable in development and AnyCable in production. I think it’s little bit complicated way to use only AnyCable, but it more robust.


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