Multi-tenancy vs. Cables:
Introducing Action Cable command callbacks

July 5, 2022


Introducing multi-tenancy to a web application usually comes at a price: we need to (re–)design a database schema, make sure all kinds of “requests” are bound to the right tenants, and so on. Luckily, for Rails applications we have battle-tested tools to make developers' lives easier. However, all of them focus on classic Rails components, controllers, and background jobs. Who will take care of the channels?

Execution context, or how tenant scoping is usually implemented

Multi-tenancy could be implemented in many different ways, but most of them include the following phases: 1) retrieving a tenant (e.g., from request properties), and 2) storing the current tenant within the current execution context.

What is execution context? We might say that it’s a unit of work in a web application, with a clearly defined beginning and end. Web requests and background jobs are examples of execution contexts.

In Ruby, an execution context is usually connected to a single Thread or Fiber. Thus, most multi-tenancy libraries use Fiber local variables to store the current tenant information. For example, acts_as_tenant relies on the good ole request_store gem, which provides a wrapper for Thread.current and takes care of clearing the state when a request completes. All you need is to set a tenant in your controller (usually, in a before_action hook):

class ApplicationController < ActionController::Base
  before_action do
    current_account = find_account_from_request_or_whatever
    set_current_tenant(current_account)
  end
end

Easily done. We have lifecycle APIs in our controllers (action callbacks), which make injecting some logic before (or after) any unit of work pretty straightforward. We can also go one step above and rely on Rack middlewares (like Apartment does).

What about Action Cable?

First, let’s think—what is the execution context for sockets? Cable connections are persistent and long-lived; they have a beginning (connect) and end (disconnect), but these are not our execution context boundaries. So, what are they then?

The way Action Cable works under the hood could give us a hint. How many concurrent clients could be handled by a Ruby server? (We’re not talking about AnyCable right now). Maybe, we could spawn a Thread per connection? That would quickly blow up due to high resource usage. Instead, Action Cable relies on an event loop and a Thread pool executor (i.e., a fixed number of worker threads). Whenever we need to process an incoming “message” from a client, we fetch a worker Thread from the pool and use it to process the message. And this is our unit of work (and a random Thread from the pool is our execution context). I put the “message” in quotes because we also use the pool to process connection initialization (Connection#connect) and closure (Connection#disconnect) events, which are not messages.

Now, let’s take a look at the naive approach to configuring a tenant for Action Cable connections:

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

    def connect
      self.tenant = request.subdomain
      Apartment::Tenant.switch!(tenant)
    end
  end
end

Looks similar to what we do in controllers, right? The problem here is that when the next message (say, channel subscription) is processed by this connection, we may have incorrect tenant information because the execution context has likely changed (a different Thread is processing the message). This could mess things up.

NOTE: AnyCable also uses a thread pool under the hood (it’s a part of a gRPC server).

We can probably fix this by adding before_subscribe to our ApplicationCable::Channel and calling switch!(tenant) there, too. And we should probably add after_subscribe to reset the state (otherwise our tenant could leak into Connection#connect and Connection#disconnect methods).

Alternatively, we can hack around the Connection class and make sure the correct tenant is set up before we enter channels:

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    # ...

    # Make all channel commands tenant-aware
    def dispatch_websocket_message(*)
      using_current_tenant { super }
    end
  end
end

This is my preferred way of dealing with multi-tenancy. I believe that the connection is the right place for dealing with scoping, and that channels should not deal with it. The only problem with this approach is that it relies on Action Cable internals. And it’s also incompatible with AnyCable (which doesn’t use #dispatch_websocket_message), so we had to patch two methods to work with AnyCable:

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    # ...

    # Make all channel commands tenant-aware
    def dispatch_websocket_message(*)
      using_current_tenant { super }
    end

    # The same override for AnyCable, which uses a different method
    def handle_channel_command(*)
      using_current_tenant { super }
    end
  end
end

The patching and duplication didn’t look good to me, so I decided to fix it once and for all—let me share a bit of Rails 7.1 with you.

Action Cable around_command to the rescue

The search for a better API didn’t take too long: Rails is built on top of conventions, and there is no better way to extend the framework than to follow these conventions. In this particular case, I decided to go with callbacks. Every Rails developer is familiar with callbacks, right?

I’m glad to introduce command callbacks for Connection classes: before_command, after_command, and around_command. They do literally what they say: allow you to execute the code before, after, or around channel commands.

And this is how our multi-tenancy problem could be solved via the around_command callback:

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    around_command :set_current_tenant

    attr_reader :tenant

    def connect
      @tenant = request.subdomain
    end

    private

    def set_current_tenant
      with_tenant(tenant) { yield }
    end
  end
end

Awesome! The only downside is that it’s only available since Rails 7.1.

We made AnyCable compatible with this feature, but there’s more: our Rails integration includes a backport for command callbacks for older Rails versions. Just drop anycable-rails into your Gemfile and use future Rails APIs!

Finally, let’s take about one importan thing that left—tests. How to make sure our command callbacks actually work? Below you can find the annotated snippet for RSpec:

describe ApplicationCable::Connection do
  let(:tenant) { create :tenant }
  let(:user) { create(:user, tenant:) }
  let(:chat_room) { create(:chat_room, tenant:) }

  describe "#set_current_tenant callback" do
    # We use a custom channel class just for these tests
    # to avoid depending on real channels from the app
    before do
      stub_const("TestChatChannel", Class.new(ApplicationCable::Channel) do
        cattr_accessor :found_user
        cattr_accessor :subscribed

        def subscribed
          # Use this flag to make sure we reached the #subscribed callback
          self.class.subscribed = true
          # Use this value to verify that tenant scoping has been preserved
          self.class.found_room = ChatRoom.find_by(id: params["id"])
        end
      end)
    end

    # Assume that we use cookies for authentication
    before { cookies.signed["user_id"] = user.id }

    # This is the client's command we want to process by the connection
    let(:command) do
      {
        "identifier" => {channel: "TestChatChannel", id: room.id}.to_json,
        "command" => "subscribe"
      }
    end

    specify do
      connection.handle_channel_command(command)
      expect(TestChatChannel.subscribed).to be true
      expect(TestChatChannel.found_room).to eq(room)
    end

    context "when user is from another tenant" do
      let(:user) { create(:user) }

      specify do
        connection.handle_channel_command(command)
        expect(TestChatChannel.subscribed).to be true
        expect(TestChatChannel.found_room).to be_nil
      end
    end
  end
end

We only considered a single use case for command callbacks, though there are plenty of others. For example, you could set the current user’s time zone or locale, or provide some context via Current attributes or dry-effects.

Give this feature a try with anycable-rails today! (Even if you’re not using AnyCable… yet 😉)