Skip to content

Commit 6f02a2a

Browse files
Add automatic shard swapping middleware
This PR adds a middleware that can be used for automatic shard swapping. The design is similar to the database selector middleware in that the resolver is provided by the application to determine which shard to switch to. The selector also takes options to change the default behavior of the middleware. The only supported option is `lock` at the moment which will allow shard swapping in a request, otherwise it defaults to true and prevents shard swapping. This will help protect mistakenly switching shards inside of application code in a multi-tenant application. The resolver can be designed however the application wants but the basic idea is that the resolver accesses the `request` headers and uses that to lookup the subdomain which then looks up the tenant shard name stored in that table. The tenant table is the "router" for the entire application and an example resolver looks like this: ```ruby config.active_record.shard_resolver = ->(request) { subdomain = request.subdomain tenant = Tenant.find_by_subdomain!(subdomain) tenant.shard } ``` The `Tenant` table in this example would have `subdomain` and `shard` attributes that are inserted into the database. These are used to route to the shard in `connected_to`. Ie if we had a `Tenant` with the subdomain `github` and the shard name `github_shard` we'd lookup the connection with `ActiveRecord::Base.connected_to(shard: :github_shard) {}` and all queries within that block (request) would be scoped to the github shard. Co-authored-by: John Crepezzi <[email protected]>
1 parent a0e14a8 commit 6f02a2a

File tree

10 files changed

+195
-5
lines changed

10 files changed

+195
-5
lines changed

activerecord/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
* Add middleware for automatic shard swapping.
2+
3+
Provides a basic middleware to perform automatic shard swapping. Applications will provide a resolver which will determine for an individual request which shard should be used. Example:
4+
5+
```ruby
6+
config.active_record.shard_resolver = ->(request) {
7+
subdomain = request.subdomain
8+
tenant = Tenant.find_by_subdomain!(subdomain)
9+
tenant.shard
10+
}
11+
```
12+
13+
See guides for more details.
14+
15+
*Eileen M. Uchitelle*, *John Crepezzi*
16+
117
* Remove deprecated support to pass a column to `type_cast`.
218

319
*Rafael Mendonça França*

activerecord/lib/active_record.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ module Middleware
155155
extend ActiveSupport::Autoload
156156

157157
autoload :DatabaseSelector, "active_record/middleware/database_selector"
158+
autoload :ShardSelector, "active_record/middleware/shard_selector"
158159
end
159160

160161
module Tasks

activerecord/lib/active_record/connection_handling.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,14 @@ def connecting_to(role: default_role, shard: default_shard, prevent_writes: fals
211211
# nested call to connected_to or connected_to_many to swap again. This
212212
# is useful in cases you're using sharding to provide per-request
213213
# database isolation.
214-
def prohibit_shard_swapping
215-
Thread.current.thread_variable_set(:prohibit_shard_swapping, true)
214+
def prohibit_shard_swapping(enabled = true)
215+
prev_value = Thread.current.thread_variable_get(:prohibit_shard_swapping)
216+
217+
Thread.current.thread_variable_set(:prohibit_shard_swapping, enabled)
218+
216219
yield
217220
ensure
218-
Thread.current.thread_variable_set(:prohibit_shard_swapping, false)
221+
Thread.current.thread_variable_set(:prohibit_shard_swapping, prev_value)
219222
end
220223

221224
# Determine whether or not shard swapping is currently prohibited

activerecord/lib/active_record/core.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ def self.configurations
7777

7878
class_attribute :default_shard, instance_writer: false
7979

80+
class_attribute :shard_selector, instance_accessor: false, default: nil
81+
8082
def self.application_record_class? # :nodoc:
8183
if ActiveRecord.application_record_class
8284
self == ActiveRecord.application_record_class
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
require "active_record/middleware/database_selector/resolver"
4+
5+
module ActiveRecord
6+
module Middleware
7+
# The ShardSelector Middleware provides a framework for automatically
8+
# swapping shards. Rails provides a basic framework to determine which
9+
# shard to switch to and allows for applications to write custom strategies
10+
# for swapping if needed.
11+
#
12+
# The ShardSelector takes a set of options (currently only `lock` is supported)
13+
# that can be used by the middleware to alter behavior. `lock` is
14+
# true by default and will prohibit the request from switching shards once
15+
# inside the block. If `lock` is false, then shard swapping will be allowed.
16+
# For tenant based sharding, `lock` should always be true to prevent application
17+
# code from mistakenly switching between tenants.
18+
#
19+
# Options can be set in the config:
20+
#
21+
# config.active_record.shard_selector = { lock: true }
22+
#
23+
# Applications must also provide the code for the resolver as it depends on application
24+
# specific models. An example resolver would look like this:
25+
#
26+
# config.active_record.shard_resolver = ->(request) {
27+
# subdomain = request.subdomain
28+
# tenant = Tenant.find_by_subdomain!(subdomain)
29+
# tenant.shard
30+
# }
31+
class ShardSelector
32+
def initialize(app, resolver, options = {})
33+
@app = app
34+
@resolver = resolver
35+
@options = options
36+
end
37+
38+
attr_reader :resolver, :options
39+
40+
def call(env)
41+
request = ActionDispatch::Request.new(env)
42+
43+
shard = selected_shard(request)
44+
45+
set_shard(shard) do
46+
@app.call(env)
47+
end
48+
end
49+
50+
private
51+
def selected_shard(request)
52+
resolver.call(request)
53+
end
54+
55+
def set_shard(shard, &block)
56+
ActiveRecord::Base.connected_to(shard: shard.to_sym) do
57+
ActiveRecord::Base.prohibit_shard_swapping(options.fetch(:lock, true), &block)
58+
end
59+
end
60+
end
61+
end
62+
end

activerecord/lib/active_record/railtie.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ class Railtie < Rails::Railtie # :nodoc:
102102
end
103103
end
104104

105+
initializer "active_record.shard_selector" do
106+
if resolver = config.active_record.shard_resolver
107+
options = config.active_record.shard_selector || {}
108+
109+
config.app_middleware.use ActiveRecord::Middleware::ShardSelector, resolver, options
110+
end
111+
end
112+
105113
initializer "Check for cache versioning support" do
106114
config.after_initialize do |app|
107115
ActiveSupport.on_load(:active_record) do
@@ -232,6 +240,8 @@ class Railtie < Rails::Railtie # :nodoc:
232240
:database_selector,
233241
:database_resolver,
234242
:database_resolver_context,
243+
:shard_selector,
244+
:shard_resolver,
235245
:query_log_tags_enabled,
236246
:query_log_tags,
237247
:cache_query_log_tags,
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/helper"
4+
require "models/person"
5+
require "action_dispatch"
6+
7+
module ActiveRecord
8+
class ShardSelectorTest < ActiveRecord::TestCase
9+
def test_middleware_locks_to_shard_by_default
10+
middleware = ActiveRecord::Middleware::ShardSelector.new(lambda { |env|
11+
assert_predicate ActiveRecord::Base, :shard_swapping_prohibited?
12+
[200, {}, ["body"]]
13+
}, ->(*) { :shard_one })
14+
15+
assert_equal [200, {}, ["body"]], middleware.call("REQUEST_METHOD" => "GET")
16+
end
17+
18+
def test_middleware_can_turn_off_lock_option
19+
middleware = ActiveRecord::Middleware::ShardSelector.new(lambda { |env|
20+
assert_not_predicate ActiveRecord::Base, :shard_swapping_prohibited?
21+
[200, {}, ["body"]]
22+
}, ->(*) { :shard_one }, { lock: false })
23+
24+
assert_equal [200, {}, ["body"]], middleware.call("REQUEST_METHOD" => "GET")
25+
end
26+
27+
def test_middleware_can_change_shards
28+
middleware = ActiveRecord::Middleware::ShardSelector.new(lambda { |env|
29+
assert ActiveRecord::Base.connected_to?(role: :writing, shard: :shard_one)
30+
[200, {}, ["body"]]
31+
}, ->(*) { :shard_one })
32+
33+
assert_equal [200, {}, ["body"]], middleware.call("REQUEST_METHOD" => "GET")
34+
end
35+
36+
def test_middleware_can_handle_string_shards
37+
middleware = ActiveRecord::Middleware::ShardSelector.new(lambda { |env|
38+
assert ActiveRecord::Base.connected_to?(role: :writing, shard: :shard_one)
39+
[200, {}, ["body"]]
40+
}, ->(*) { "shard_one" })
41+
42+
assert_equal [200, {}, ["body"]], middleware.call("REQUEST_METHOD" => "GET")
43+
end
44+
end
45+
end

guides/source/active_record_multiple_databases.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ databases
3131

3232
The following features are not (yet) supported:
3333

34-
* Automatic swapping for horizontal sharding
3534
* Load balancing replicas
3635

3736
## Setting up your application
@@ -276,7 +275,7 @@ $ bin/rails generate scaffold Dog name:string --database animals --parent Animal
276275
This will skip generating `AnimalsRecord` since you've indicated to Rails that you want to
277276
use a different parent class.
278277

279-
## Activating automatic connection switching
278+
## Activating automatic role switching
280279

281280
Finally, in order to use the read-only replica in your application, you'll need to activate
282281
the middleware for automatic switching.
@@ -426,6 +425,40 @@ ActiveRecord::Base.connected_to(role: :reading, shard: :shard_one) do
426425
end
427426
```
428427

428+
## Activating automatic shard switching
429+
430+
Applications are able to automatically switch shards per request using the provided
431+
middleware.
432+
433+
The ShardSelector Middleware provides a framework for automatically
434+
swapping shards. Rails provides a basic framework to determine which
435+
shard to switch to and allows for applications to write custom strategies
436+
for swapping if needed.
437+
438+
The ShardSelector takes a set of options (currently only `lock` is supported)
439+
that can be used by the middleware to alter behavior. `lock` is
440+
true by default and will prohibit the request from switching shards once
441+
inside the block. If `lock` is false, then shard swapping will be allowed.
442+
For tenant based sharding, `lock` should always be true to prevent application
443+
code from mistakenly switching between tenants.
444+
445+
Options can be set in the config:
446+
447+
```ruby
448+
config.active_record.shard_selector = { lock: true }
449+
```
450+
451+
Applications must also provide the code for the resolver as it depends on application
452+
specific models. An example resolver would look like this:
453+
454+
```ruby
455+
config.active_record.shard_resolver = ->(request) {
456+
subdomain = request.subdomain
457+
tenant = Tenant.find_by_subdomain!(subdomain)
458+
tenant.shard
459+
}
460+
```
461+
429462
## Migrate to the new connection handling
430463

431464
In Rails 6.1+, Active Record provides a new internal API for connection management.

railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,14 @@ Rails.application.configure do
125125
# config.active_record.database_selector = { delay: 2.seconds }
126126
# config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
127127
# config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
128+
129+
# Inserts middleware to perform automatic shard swapping. The `shard_selector` hash
130+
# can be used to pass options to the `ShardSelector` middleware. The `lock` option is
131+
# used to determine whether shard swapping should be prohibited for the request.
132+
#
133+
# The `shard_resolver` option is used by the middleware to determine which shard
134+
# to switch to. The application must provide a mechanism for finding the shard name
135+
# in a proc. See guides for an example.
136+
# config.active_record.shard_selector = { lock: true }
137+
# config.active_record.shard_resolver = ->(request) { Tenant.find_by!(host: request.host).shard }
128138
end

railties/test/application/middleware_test.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,14 @@ def index
345345
assert_equal "/foo/?something", env["ORIGINAL_FULLPATH"]
346346
end
347347

348+
test "shard selector middleware is installed by config option" do
349+
add_to_config "config.active_record.shard_resolver = ->(*) { }"
350+
351+
boot!
352+
353+
assert_includes middleware, "ActiveRecord::Middleware::ShardSelector"
354+
end
355+
348356
private
349357
def boot!
350358
require "#{app_path}/config/environment"

0 commit comments

Comments
 (0)