active_record_shards 2.0.0.beta2 → 2.0.0.beta5

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,98 @@
1
+ # ActiveRecord Shards
2
+
3
+ ActiveRecord Shards is an extension for ActiveRecord that provides support for sharded database and slaves. Basically it is just a nice way to
4
+ switch between database connection. We've made the implementation very small, and have tried not to reinvent any wheels already present in ActiveRecord.
5
+
6
+ ActiveRecord Shards has only been used and tested on Rails 2.3.x, and has in some form or another been used on in production on a large rails app for
7
+ more than a year.
8
+
9
+ ## Installation
10
+
11
+ $ gem install active_record_shards
12
+
13
+ and make sure to require 'active\_record\_shards' in some way.
14
+
15
+ ## Configuration
16
+
17
+ Add the slave and shard configuration to config/database.yml:
18
+
19
+ production:
20
+ adapter: mysql
21
+ encoding: utf8
22
+ database: my_app_main
23
+ pool: 5
24
+ host: db1
25
+ username: root
26
+ password:
27
+ slave:
28
+ host: db1_slave
29
+ shards:
30
+ 1:
31
+ host: db_shard1
32
+ database: my_app_shard
33
+ slave:
34
+ host: db_shard1_slave
35
+ 2:
36
+ host: db_shard2
37
+ database: my_app_shard
38
+ slave:
39
+ host: db_shard2_slave
40
+
41
+ basically connections inherit configuration from the parent the configuration file.
42
+
43
+ ## Usage
44
+
45
+ Normally you have some models that live on a shared database, and you might need to query this data in order to know what shard to switch to.
46
+ All the model that live on the shared database must be marked as not\_sharded:
47
+
48
+ class Account < ActiveRecord::Base
49
+ not_sharded
50
+
51
+ has_many :projects
52
+ end
53
+
54
+ class Project < ActiveRecord::Base
55
+ belongs_to :account
56
+ end
57
+
58
+ So in this setup the accounts live on the shared database, but the projects are sharded. If accounts had a shard\_id column, you could lookup the account
59
+ in a rack middleware and switch to the right shard:
60
+
61
+ class AccountMiddleware
62
+ def initialize(app)
63
+ @app = app
64
+ end
65
+
66
+ def call(env)
67
+ account = lookup_account(env)
68
+
69
+ if account
70
+ ActiveRecord::Base.on_shard(account.shard_id) do
71
+ @app.call(env)
72
+ end
73
+ else
74
+ @app.call(env)
75
+ end
76
+ end
77
+
78
+ def lookup_account(env)
79
+ ...
80
+ end
81
+ end
82
+
83
+ Any where in your app, you can switch to the slave databases, by wrapping you code an on\_slave block:
84
+
85
+ ActiveRecord::Base.on_slave do
86
+ Account.find_by_big_expensive_query
87
+ end
88
+
89
+ This will perform the query on the slave, and mark the returned instances as read only. There is also a shortcut for this:
90
+
91
+ Account.on_slave.find_by_big_expensive_query
92
+
93
+ ## Copyright
94
+
95
+ Copyright (c) 2011 Zendesk. See LICENSE for details.
96
+
97
+ ## Authors
98
+ Mick Staugaard, Eric Chapweske
@@ -6,6 +6,7 @@ module ActiveRecordShards
6
6
  conf.keys.each do |env_name|
7
7
  env_config = conf[env_name]
8
8
  if shards = env_config.delete('shards')
9
+ env_config['shard_names'] = shards.keys
9
10
  shards.each do |shard_name, shard_conf|
10
11
  expand_child!(env_config, shard_conf)
11
12
  conf["#{env_name}_shard_#{shard_name}"] = shard_conf
@@ -1,12 +1,30 @@
1
1
  module ActiveRecordShards
2
2
  module ConnectionSwitcher
3
+ def default_shard=(new_default_shard)
4
+ ActiveRecordShards::ShardSelection.default_shard = new_default_shard
5
+ switch_connection(:shard => new_default_shard)
6
+ end
3
7
 
4
8
  def on_shard(shard, &block)
5
- old_shard = current_shard_selection.shard
6
- switch_connection(:shard => shard)
9
+ old_options = current_shard_selection.options
10
+ switch_connection(:shard => shard) if supports_sharding?
7
11
  yield
8
12
  ensure
9
- switch_connection(:shard => old_shard)
13
+ switch_connection(old_options)
14
+ end
15
+
16
+ def on_all_shards(&block)
17
+ old_options = current_shard_selection.options
18
+ if supports_sharding?
19
+ shard_names.each do |shard|
20
+ switch_connection(:shard => shard)
21
+ yield
22
+ end
23
+ else
24
+ yield
25
+ end
26
+ ensure
27
+ switch_connection(old_options)
10
28
  end
11
29
 
12
30
  def on_slave_if(condition, &block)
@@ -40,28 +58,26 @@ module ActiveRecordShards
40
58
  alias_method :with_slave_unless, :on_slave_unless
41
59
 
42
60
  def on_slave_block(&block)
43
- old_slave = current_shard_selection.on_slave?
44
- begin
45
- switch_connection(:slave => true)
46
- rescue ActiveRecord::AdapterNotSpecified => e
47
- switch_connection(:slave => old_slave)
48
- logger.warn("Failed to establish shard connection: #{e.message} - defaulting to master")
49
- end
61
+ old_options = current_shard_selection.options
62
+ switch_connection(:slave => true)
50
63
  with_scope({:find => {:readonly => current_shard_selection.on_slave?}}, :merge, &block)
51
64
  ensure
52
- switch_connection(:slave => old_slave)
65
+ switch_connection(old_options)
53
66
  end
54
67
 
55
68
  # Name of the connection pool. Used by ConnectionHandler to retrieve the current connection pool.
56
69
  def connection_pool_name # :nodoc:
57
- current_shard_selection.shard_name(self)
58
- # if current_shard_selection.any?
59
- # current_shard_selection.shard_name(name, self)
60
- # elsif self == ActiveRecord::Base
61
- # name
62
- # else
63
- # superclass.connection_pool_name
64
- # end
70
+ name = current_shard_selection.shard_name(self)
71
+
72
+ if configurations[name].nil? && current_shard_selection.on_slave?
73
+ current_shard_selection.shard_name(self, false)
74
+ else
75
+ name
76
+ end
77
+ end
78
+
79
+ def supports_sharding?
80
+ shard_names.any?
65
81
  end
66
82
 
67
83
  private
@@ -84,9 +100,18 @@ module ActiveRecordShards
84
100
  end
85
101
  end
86
102
 
103
+ def shard_names
104
+ configurations[shard_env]['shard_names'] || []
105
+ end
106
+
107
+ def shard_env
108
+ @shard_env = defined?(Rails.env) ? Rails.env : RAILS_ENV
109
+ end
110
+
87
111
  def establish_shard_connection
88
- name = current_shard_selection.shard_name(self)
112
+ name = connection_pool_name
89
113
  spec = configurations[name]
114
+
90
115
  raise ActiveRecord::AdapterNotSpecified.new("No database defined by #{name} in database.yml") if spec.nil?
91
116
 
92
117
  connection_handler.establish_connection(connection_pool_name, ActiveRecord::Base::ConnectionSpecification.new(spec, "#{spec['adapter']}_connection"))
@@ -1,15 +1,24 @@
1
1
  module ActiveRecordShards
2
2
  class ShardSelection
3
+ NO_SHARD = :_no_shard
4
+ cattr_accessor :default_shard
5
+
3
6
  def initialize
4
7
  @on_slave = false
5
8
  end
6
9
 
7
- def shard
8
- @shard
10
+ def shard(klass = nil)
11
+ if (@shard || self.class.default_shard) && (klass.nil? || klass.is_sharded?)
12
+ if @shard == NO_SHARD
13
+ nil
14
+ else
15
+ (@shard || self.class.default_shard).to_s
16
+ end
17
+ end
9
18
  end
10
19
 
11
20
  def shard=(new_shard)
12
- @shard = new_shard
21
+ @shard = (new_shard || NO_SHARD)
13
22
  end
14
23
 
15
24
  def on_slave?
@@ -20,22 +29,20 @@ module ActiveRecordShards
20
29
  @on_slave = (new_slave == true)
21
30
  end
22
31
 
23
- def connection_configuration_name
24
- shard_name#(RAILS_ENV)
25
- end
26
-
27
- def shard_name(klass = nil)
32
+ def shard_name(klass = nil, try_slave = true)
28
33
  s = "#{RAILS_ENV}"
29
- if @shard && (klass.nil? || klass.is_sharded?)
34
+ if the_shard = shard(klass)
30
35
  s << '_shard_'
31
- s << @shard.to_s
36
+ s << the_shard
37
+ end
38
+ if @on_slave && try_slave
39
+ s << "_slave" if @on_slave
32
40
  end
33
- s << "_slave" if @on_slave
34
41
  s
35
42
  end
36
43
 
37
- def any?
38
- @on_slave || @shard.present?
44
+ def options
45
+ {:shard => @shard, :slave => @on_slave}
39
46
  end
40
47
  end
41
48
  end
@@ -0,0 +1,52 @@
1
+ require 'active_record_shards'
2
+
3
+ Rake::TaskManager.class_eval do
4
+ def remove_task(task_name)
5
+ @tasks.delete(task_name.to_s)
6
+ end
7
+ end
8
+
9
+ def remove_task(task_name)
10
+ Rake.application.remove_task(task_name)
11
+ end
12
+
13
+ remove_task 'db:drop'
14
+ remove_task 'db:create'
15
+ remove_task 'db:abort_if_pending_migrations'
16
+
17
+ namespace :db do
18
+ desc 'Drops the database for the current RAILS_ENV including shards and slaves'
19
+ task :drop => :load_config do
20
+ env_name = defined?(Rails.env) ? Rails.env : RAILS_ENV || 'development'
21
+ ActiveRecord::Base.configurations.each do |key, conf|
22
+ if key.starts_with?(env_name) && !key.ends_with?("_slave")
23
+ drop_database(conf)
24
+ end
25
+ end
26
+ end
27
+
28
+ desc "Create the database defined in config/database.yml for the current RAILS_ENV including shards and slaves"
29
+ task :create => :load_config do
30
+ env_name = defined?(Rails.env) ? Rails.env : RAILS_ENV || 'development'
31
+ ActiveRecord::Base.configurations.each do |key, conf|
32
+ if key.starts_with?(env_name) && !key.ends_with?("_slave")
33
+ create_database(conf)
34
+ end
35
+ end
36
+ end
37
+
38
+ desc "Raises an error if there are pending migrations"
39
+ task :abort_if_pending_migrations => :environment do
40
+ if defined? ActiveRecord
41
+ pending_migrations = ActiveRecord::Base.on_shard(nil) { ActiveRecord::Migrator.new(:up, 'db/migrate').pending_migrations }
42
+
43
+ if pending_migrations.any?
44
+ puts "You have #{pending_migrations.size} pending migrations:"
45
+ pending_migrations.each do |pending_migration|
46
+ puts ' %4d %s' % [pending_migration.version, pending_migration.name]
47
+ end
48
+ abort %{Run "rake db:migrate" to update your database then try again.}
49
+ end
50
+ end
51
+ end
52
+ end
@@ -10,13 +10,14 @@ class ConfigurationParserTest < ActiveSupport::TestCase
10
10
  setup { @conf = @exploded_conf['test_slave'] }
11
11
  should "be exploded" do
12
12
  assert_equal({
13
- "adapter" => "mysql",
14
- "encoding" => "utf8",
15
- "database" => "ars_test",
16
- "port" => 123,
17
- "username" => "root",
18
- "password" => nil,
19
- "host" => "main_slave_host"
13
+ "adapter" => "mysql",
14
+ "encoding" => "utf8",
15
+ "database" => "ars_test",
16
+ "port" => 123,
17
+ "username" => "root",
18
+ "password" => nil,
19
+ "host" => "main_slave_host",
20
+ "shard_names" => ["a", "b"]
20
21
  }, @conf)
21
22
  end
22
23
  end
@@ -26,13 +27,14 @@ class ConfigurationParserTest < ActiveSupport::TestCase
26
27
  setup { @conf = @exploded_conf['test_shard_a'] }
27
28
  should "be exploded" do
28
29
  assert_equal({
29
- "adapter" => "mysql",
30
- "encoding" => "utf8",
31
- "database" => "ars_test_shard_a",
32
- "port" => 123,
33
- "username" => "root",
34
- "password" => nil,
35
- "host" => "shard_a_host"
30
+ "adapter" => "mysql",
31
+ "encoding" => "utf8",
32
+ "database" => "ars_test_shard_a",
33
+ "port" => 123,
34
+ "username" => "root",
35
+ "password" => nil,
36
+ "host" => "shard_a_host",
37
+ "shard_names" => ["a", "b"]
36
38
  }, @conf)
37
39
  end
38
40
  end
@@ -41,13 +43,14 @@ class ConfigurationParserTest < ActiveSupport::TestCase
41
43
  setup { @conf = @exploded_conf['test_shard_a_slave'] }
42
44
  should "be exploded" do
43
45
  assert_equal({
44
- "adapter" => "mysql",
45
- "encoding" => "utf8",
46
- "database" => "ars_test_shard_a",
47
- "port" => 123,
48
- "username" => "root",
49
- "password" => nil,
50
- "host" => "shard_a_slave_host"
46
+ "adapter" => "mysql",
47
+ "encoding" => "utf8",
48
+ "database" => "ars_test_shard_a",
49
+ "port" => 123,
50
+ "username" => "root",
51
+ "password" => nil,
52
+ "host" => "shard_a_slave_host",
53
+ "shard_names" => ["a", "b"]
51
54
  }, @conf)
52
55
  end
53
56
  end
@@ -58,13 +61,14 @@ class ConfigurationParserTest < ActiveSupport::TestCase
58
61
  setup { @conf = @exploded_conf['test_shard_b'] }
59
62
  should "be exploded" do
60
63
  assert_equal({
61
- "adapter" => "mysql",
62
- "encoding" => "utf8",
63
- "database" => "ars_test_shard_b",
64
- "port" => 123,
65
- "username" => "root",
66
- "password" => nil,
67
- "host" => "shard_b_host"
64
+ "adapter" => "mysql",
65
+ "encoding" => "utf8",
66
+ "database" => "ars_test_shard_b",
67
+ "port" => 123,
68
+ "username" => "root",
69
+ "password" => nil,
70
+ "host" => "shard_b_host",
71
+ "shard_names" => ["a", "b"]
68
72
  }, @conf)
69
73
  end
70
74
  end
@@ -73,13 +77,14 @@ class ConfigurationParserTest < ActiveSupport::TestCase
73
77
  setup { @conf = @exploded_conf['test_shard_b_slave'] }
74
78
  should "be exploded" do
75
79
  assert_equal({
76
- "adapter" => "mysql",
77
- "encoding" => "utf8",
78
- "database" => "ars_test_shard_b_slave",
79
- "port" => 123,
80
- "username" => "root",
81
- "password" => nil,
82
- "host" => "shard_b_host"
80
+ "adapter" => "mysql",
81
+ "encoding" => "utf8",
82
+ "database" => "ars_test_shard_b_slave",
83
+ "port" => 123,
84
+ "username" => "root",
85
+ "password" => nil,
86
+ "host" => "shard_b_host",
87
+ "shard_names" => ["a", "b"]
83
88
  }, @conf)
84
89
  end
85
90
  end
@@ -32,8 +32,102 @@ class ConnectionSwitchenTest < ActiveSupport::TestCase
32
32
  assert_using_database('ars_test')
33
33
  ActiveRecord::Base.on_slave { assert_using_database('ars_test_slave') }
34
34
  end
35
+
36
+ context "on_all_shards" do
37
+ setup do
38
+ @shard_0_master = ActiveRecord::Base.on_shard(0) {ActiveRecord::Base.connection}
39
+ @shard_1_master = ActiveRecord::Base.on_shard(1) {ActiveRecord::Base.connection}
40
+ assert_not_equal(@shard_0_master.select_value("SELECT DATABASE()"), @shard_1_master.select_value("SELECT DATABASE()"))
41
+
42
+ @database_names = []
43
+ ActiveRecord::Base.on_all_shards do
44
+ @database_names << ActiveRecord::Base.connection.select_value("SELECT DATABASE()")
45
+ end
46
+ end
47
+
48
+ should "execute the block on all shard masters" do
49
+ assert_equal(2, @database_names.size)
50
+ assert_contains(@database_names, @shard_0_master.select_value("SELECT DATABASE()"))
51
+ assert_contains(@database_names, @shard_1_master.select_value("SELECT DATABASE()"))
52
+ end
53
+ end
35
54
  end
36
-
55
+
56
+ context "default shard selection" do
57
+ context "of nil" do
58
+ setup do
59
+ ActiveRecord::Base.default_shard = nil
60
+ end
61
+
62
+ should "use unsharded db for sharded models" do
63
+ assert_using_database('ars_test', Ticket)
64
+ assert_using_database('ars_test', Account)
65
+ end
66
+ end
67
+
68
+ context "value" do
69
+ setup do
70
+ ActiveRecord::Base.default_shard = 0
71
+ end
72
+
73
+ teardown do
74
+ ActiveRecord::Base.default_shard = nil
75
+ end
76
+
77
+ should "use default shard db for sharded models" do
78
+ assert_using_database('ars_test_shard0', Ticket)
79
+ assert_using_database('ars_test', Account)
80
+ end
81
+
82
+ should "still be able to switch to shard nil" do
83
+ ActiveRecord::Base.on_shard(nil) do
84
+ assert_using_database('ars_test', Ticket)
85
+ assert_using_database('ars_test', Account)
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ context "in an unsharded environment" do
92
+ setup do
93
+ silence_warnings { ::RAILS_ENV = 'test2' }
94
+ ActiveRecord::Base.establish_connection(::RAILS_ENV)
95
+ assert_using_database('ars_test2', Ticket)
96
+ end
97
+
98
+ teardown do
99
+ silence_warnings { ::RAILS_ENV = 'test' }
100
+ ActiveRecord::Base.establish_connection(::RAILS_ENV)
101
+ assert_using_database('ars_test', Ticket)
102
+ end
103
+
104
+ context "shard switching" do
105
+ should "just stay on the main db" do
106
+ assert_using_database('ars_test2', Ticket)
107
+ assert_using_database('ars_test2', Account)
108
+
109
+ ActiveRecord::Base.on_shard(0) do
110
+ assert_using_database('ars_test2', Ticket)
111
+ assert_using_database('ars_test2', Account)
112
+ end
113
+ end
114
+ end
115
+
116
+ context "on_all_shards" do
117
+ setup do
118
+ @database_names = []
119
+ ActiveRecord::Base.on_all_shards do
120
+ @database_names << ActiveRecord::Base.connection.select_value("SELECT DATABASE()")
121
+ end
122
+ end
123
+
124
+ should "execute the block on all shard masters" do
125
+ @database_names
126
+ assert_equal([ActiveRecord::Base.connection.select_value("SELECT DATABASE()")], @database_names)
127
+ end
128
+ end
129
+ end
130
+
37
131
  context "slave driving" do
38
132
  context "without slave configuration" do
39
133