active_record_shards 2.0.0.beta2 → 2.0.0.beta5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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