Skip to content

Commit 2c96efb

Browse files
authored
Merge pull request rails#43665 from eileencodes/add-shard-middleware
Add automatic shard swapping middleware
2 parents a0e14a8 + 6f02a2a commit 2c96efb

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)