From be85f187f7eafdad879e06172987dd8e0cf68024 Mon Sep 17 00:00:00 2001 From: Vladimir Dementyev Date: Sun, 15 Oct 2023 13:03:45 -0700 Subject: [PATCH] blog: upgrade multi-tenancy post + link issue --- src/blog/multi-tenancy-vs-cables/index.md | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/blog/multi-tenancy-vs-cables/index.md b/src/blog/multi-tenancy-vs-cables/index.md index e33687c..c74a007 100644 --- a/src/blog/multi-tenancy-vs-cables/index.md +++ b/src/blog/multi-tenancy-vs-cables/index.md @@ -5,16 +5,16 @@ 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][ruby-toolbox-multitenancy] 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? +Introducing multi-tenancy to a web application usually comes at a price: we need to (re–)design a database schema, ensure all kinds of "requests" are bound to the right tenants, and so on. Luckily, for Rails applications, we have [battle-tested tools][ruby-toolbox-multitenancy] 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? {intro}
## 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_. +Multi-tenancy could be implemented in many different ways. Still, 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. +What is _execution context_? We might say 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][fiber-locals] 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](https://github.com/steveklabnik/request_store/blob/f79bd375e88f434428b876dbb5c8a51b569712aa/lib/request_store/middleware.rb#L29-L33). All you need is to set a tenant in your controller (usually, in a `before_action` hook): @@ -33,7 +33,7 @@ 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][ac-event-loop] and a [Thread pool executor][ac-thread-pool] (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. +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][ac-event-loop] and a [Thread pool executor][ac-thread-pool] (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: @@ -52,9 +52,9 @@ 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). +**NOTE:** AnyCable also uses a thread pool under the hood (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). +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 the `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: @@ -95,7 +95,7 @@ The patching and duplication didn't look good to me, so I decided to fix it once ## 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? +The search for a better API didn't take 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][rails-pr]: `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. @@ -125,7 +125,7 @@ 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`][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: +Finally, let's talk about one important thing left—tests. How do we make sure our command callbacks actually work? Below, you can find the annotated snippet for RSpec: ```ruby describe ApplicationCable::Connection do @@ -182,10 +182,14 @@ 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][current] or [dry-effects][]. +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][current] or [dry-effects][]. Give this feature a try with [anycable-rails][] today! (Even if you're not using AnyCable... yet 😉) +--- + +P.S. Using the `apartment` gem with AnyCable requires a bit more attention in case you store Active Record objects as connection identifiers. See [this issue](https://github.com/anycable/anycable-rails/issues/179). + [ruby-toolbox-multitenancy]: https://www.ruby-toolbox.com/categories/Multitenancy [rails-pr]: https://github.com/rails/rails/pull/44696 [current]: https://edgeapi.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html