-2

How do I get a list of all ActiveRecord models currently subscribed to via a specific ActionCable Channel?

Favourite Onwuemene
  • 4,247
  • 8
  • 28
  • 46

2 Answers2

1

I'm sorry to give you an answer you do not want, but...:

You don't get a list of all subscribed clients. You shouldn't be able to. If you need this information, you might be experiencing a design flaw.

Why?

The pub/sub paradigm is designed to abstract these details away, allowing for horizontal scaling in a way that has different nodes managing their own subscription lists.

Sure, when you're running a single application on a single process, you might be able to extract this information - but the moment you scale up, using more processes / machines, this information is distributed and isn't available any more.

Example?

For example, when using iodine's pub/sub engine (see the Ruby iodine WebSocket / HTTP server for details):

  • Each process manages it's own client list.

  • Each process is a "client" in the master / root process.

  • Each master / root process is a client in a Redis server (assuming Redis is used).

Let's say you run two iodine "dynos" on Heroku, each with 16 workers, then:

  • Redis sees a maximum of two clients per channel.

  • Each of the two master processes sees a maximum of 16 clients per channel.

  • Each process sees only the clients that are connected to that specific process.

As you can see, the information you are asking for isn't available anywhere. The pub/sub implementation is distributed across different machines. Each process / machine only manages the small part of the pub/sub client list.

EDIT (1) - answering updated question

There are three possible approaches to solve this question:

  1. a client-side solution;

  2. a server-side solution; and

  3. a lazy (invalidation) approach.

As a client side solution, the client could register to a global "server-notifications-channel". When a "re-authenticate" message appears, the client should re-authenticate, initiating the unique token generation on it's unique connection.

A server side solution requires the server-side connection to listen to a global "server-notifications-channel". Then the connection object will re-calculate the authentication token and transmit a unique message to the client.

The lazy-invalidation approach is to simply invalidate all tokens. Connected clients will stay connected (until they close the browser, close their machine or exit their app). Clients will have to re-authenticate when establishing a new connection.

Note (added as discussed in the comments):

The only solution that solves the "thundering herd" scenario is the lazy/invalidation solution.

Any other solution will cause a spike in network traffic and CPU consumption since all connected clients will be processing an event at a similar time.

Implementing:

With ActionCable a client-side solution might be easier to implement. It's design and documentation are very "push" oriented. They often assume a client side processing approach.

On iodine, server-side subscriptions simply require a block to be passed along to the client.subscribe method. This creates a client-specific subscription with an event that runs on the server (instead of a message sent to the client).

The lazy-invalidation approach might hurt user experience, depending on the design, since they might have to re-enter credentials.

On the other hand, lazy-invalidation might be the safest, add to the appearance of safety and ease the burden on the servers at the same time.

Myst
  • 18,516
  • 2
  • 45
  • 67
  • 1
    @FavouriteOnwuemene - I updated my answer. I hope this helps. – Myst Apr 25 '19 at 12:01
  • Considering Redis holds all subscriber information, shouldn't it be possible to get that info from Redis directly? – Favourite Onwuemene Apr 26 '19 at 04:52
  • 1
    @FavouriteOnwuemene - Redis doesn't (or shouldn't) hold all subscriber information. If it does, the pub/sub system is too centralized to properly scale. In my example, Redis holds two subscribers, not all clients... However, you raise an interesting point. Redis might be aware of all **channels** when it's still limited to a single instance (isn't clustered - otherwise, it could be tricky). At this stage of your app, channel names might be used to extract possible subscriber information using the [`PUBSUB CHANNELS` command](https://redis.io/commands/pubsub) (I wouldn't recommend that). – Myst Apr 26 '19 at 05:51
  • 1
    Thanks, @Myst. I earlier experimented with the idea of a Client-side implementation but decided against it because it could cause server load spikes. Imagine there are 1,000 active users/clients, and they all receive a "re-authenticate" message. That is 1,000 request being immediately fired to the server at almost the same time. That is unless the clients can be programmed to re-authenticate in batches. – Favourite Onwuemene Apr 26 '19 at 17:51
  • @FavouriteOnwuemene - You are right, I'll update my answer. The only solution that solves the "thundering herd" scenario is the lazy/invalidation solution. Although WebSocket servers should be able to handle more concurrent traffic than HTTP servers, it's probably more network friendly to spread these re-authentications out. – Myst Apr 27 '19 at 08:54
0

WARNING: Please see @Myst answer and associated comments. The answer below isn't recommended when scaling beyond a single server instance.

PATCH REQUIRED FOR DEVELOPMENT AND TEST ENVIRONMENT

module ActionCable
  module SubscriptionAdapter
    class SubscriberMap
      def get_subscribers
        @subscribers
      end
    end
   end
end

CODE TO GET IDs OF MODELS SUBSCRIBED TO

pubsub = ActionCable.server.pubsub

if Rails.env.production?
  channel_with_prefix = pubsub.send(:channel_with_prefix, ApplicationMetaChannel.channel_name)
  channels = pubsub.send(:redis_connection).pubsub('channels', "#{channel_with_prefix}:*")
  subscriptions = channels.map do |channel|
    Base64.decode64(channel.match(/^#{Regexp.escape(channel_with_prefix)}:(.*)$/)[1])
  end
else #DEVELOPMENT or Test Environment: Requires patching ActionCable::SubscriptionAdapter::SubscriberMap
  subscribers = pubsub.send(:subscriber_map).get_subscribers.keys

  subscriptions = []
  subscribers.each do |sid|
    next unless sid.split(':').size === 2
    channel_name, encoded_gid = sid.split(':')
    if channel_name === ApplicationMetaChannel.channel_name
      subscriptions << Base64.decode64(encoded_gid)
    end
  end

end

# the GID URI looks like that: gid://<app-name>/<ActiveRecordName>/<id>
gid_uri_pattern = /^gid:\/\/.*\/#{Regexp.escape(SomeModel.name)}\/(\d+)$/
some_model_ids = subscriptions.map do |subscription|
  subscription.match(gid_uri_pattern)
  # compacting because 'subscriptions' include all subscriptions made from ApplicationMetaChannel,
  # not just subscriptions to SomeModel records
end.compact.map { |match| match[1] }
Favourite Onwuemene
  • 4,247
  • 8
  • 28
  • 46