active_record_shards 2.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
data/README.rdoc ADDED
@@ -0,0 +1,33 @@
1
+ = active_record_shards
2
+
3
+ == Example
4
+
5
+ Ticket.on_slave do
6
+ Ticket.first(50)
7
+ end
8
+
9
+ == Install
10
+
11
+ gem install active_record_shards
12
+
13
+ Add the slave database to config/database.yml:
14
+ development_slave:
15
+ adapter: mysql
16
+ ...
17
+
18
+ == Note on Patches/Pull Requests
19
+
20
+ * Fork the project.
21
+ * Make your feature addition or bug fix.
22
+ * Add tests for it. This is important so I don't break it in a
23
+ future version unintentionally.
24
+ * Commit, do not mess with rakefile, version, or history.
25
+ (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
26
+ * Send me a pull request. Bonus points for topic branches.
27
+
28
+ == Copyright
29
+
30
+ Copyright (c) 2009 Zendesk. See LICENSE for details.
31
+
32
+ == Authors
33
+ Mick Staugaard
@@ -0,0 +1,13 @@
1
+ require 'active_record'
2
+ require 'active_record/base'
3
+ require 'active_record_shards/configuration_parser'
4
+ require 'active_record_shards/model'
5
+ require 'active_record_shards/shard_selection'
6
+ require 'active_record_shards/connection_switcher'
7
+ require 'active_record_shards/association_collection_connection_selection'
8
+ require 'active_record_shards/connection_pool'
9
+
10
+ ActiveRecord::Base.extend(ActiveRecordShards::ConfigurationParser)
11
+ ActiveRecord::Base.extend(ActiveRecordShards::Model)
12
+ ActiveRecord::Base.extend(ActiveRecordShards::ConnectionSwitcher)
13
+ ActiveRecord::Associations::AssociationCollection.send(:include, ActiveRecordShards::AssociationCollectionConnectionSelection)
@@ -0,0 +1,25 @@
1
+ module ActiveRecordShards
2
+ module AssociationCollectionConnectionSelection
3
+ def on_slave_if(condition)
4
+ condition ? on_slave : self
5
+ end
6
+
7
+ def on_slave_unless(condition)
8
+ on_slave_if(!condition)
9
+ end
10
+
11
+ def on_slave
12
+ SlaveProxy.new(self)
13
+ end
14
+
15
+ class SlaveProxy
16
+ def initialize(association_collection)
17
+ @association_collection = association_collection
18
+ end
19
+
20
+ def method_missing(method, *args, &block)
21
+ @association_collection.proxy_reflection.klass.on_slave_block { @association_collection.send(method, *args, &block) }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ module ActiveRecordShards
2
+ module ConfigurationParser
3
+ module_function
4
+
5
+ def explode(conf)
6
+ conf.keys.each do |env_name|
7
+ env_config = conf[env_name]
8
+ if shards = env_config.delete('shards')
9
+ shards.each do |shard_name, shard_conf|
10
+ expand_child!(env_config, shard_conf)
11
+ conf["#{env_name}_shard_#{shard_name}"] = shard_conf
12
+ end
13
+ end
14
+ end
15
+
16
+ conf.keys.each do |env_name|
17
+ env_config = conf[env_name]
18
+ if slave_conf = env_config.delete('slave')
19
+ expand_child!(env_config, slave_conf)
20
+ conf["#{env_name}_slave"] = slave_conf
21
+ end
22
+ end
23
+ conf
24
+ end
25
+
26
+ def expand_child!(parent, child)
27
+ parent.each do |key, value|
28
+ unless ['slave', 'shards'].include?(key) || value.is_a?(Hash)
29
+ child[key] ||= value
30
+ end
31
+ end
32
+ end
33
+
34
+ def configurations_with_shard_explosion=(conf)
35
+ self.configurations_without_shard_explosion = explode(conf)
36
+ end
37
+
38
+ def ConfigurationParser.extended(klass)
39
+ klass.singleton_class.alias_method_chain :configurations=, :shard_explosion
40
+
41
+ if !klass.configurations.nil? && !klass.configurations.empty?
42
+ klass.configurations = klass.configurations
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,29 @@
1
+ ActiveRecord::ConnectionAdapters::ConnectionHandler.class_eval do
2
+ # The only difference here is that we use klass.connection_pool_name
3
+ # instead of klass.name as the pool key
4
+ def retrieve_connection_pool(klass)
5
+ pool = @connection_pools[klass.connection_pool_name]
6
+ return pool if pool
7
+ return nil if ActiveRecord::Base == klass
8
+ retrieve_connection_pool klass.superclass
9
+ end
10
+
11
+ def remove_connection(klass)
12
+ pool = @connection_pools[klass.connection_pool_name]
13
+ @connection_pools.delete_if { |key, value| value == pool }
14
+ pool.disconnect! if pool
15
+ pool.spec.config if pool
16
+ end
17
+ end
18
+
19
+ ActiveRecord::Base.singleton_class.class_eval do
20
+ def establish_connection_with_connection_pool_name(spec = nil)
21
+ case spec
22
+ when ActiveRecord::Base::ConnectionSpecification
23
+ connection_handler.establish_connection(connection_pool_name, spec)
24
+ else
25
+ establish_connection_without_connection_pool_name(spec)
26
+ end
27
+ end
28
+ alias_method_chain :establish_connection, :connection_pool_name
29
+ end
@@ -0,0 +1,104 @@
1
+ module ActiveRecordShards
2
+ module ConnectionSwitcher
3
+
4
+ def on_shard(shard, &block)
5
+ old_shard = current_shard_selection.shard
6
+ switch_connection(:shard => shard)
7
+ yield
8
+ ensure
9
+ switch_connection(:shard => old_shard)
10
+ end
11
+
12
+ def on_slave_if(condition, &block)
13
+ condition ? on_slave(&block) : yield
14
+ end
15
+
16
+ def on_slave_unless(condition, &block)
17
+ on_slave_if(!condition, &block)
18
+ end
19
+
20
+ # Executes queries using the slave database. Fails over to master if no slave is found.
21
+ # if you want to execute a block of code on the slave you can go:
22
+ # Account.on_slave do
23
+ # Account.first
24
+ # end
25
+ # the first account will be found on the slave DB
26
+ #
27
+ # For one-liners you can simply do
28
+ # Account.on_slave.first
29
+ def on_slave(&block)
30
+ if block_given?
31
+ on_slave_block(&block)
32
+ else
33
+ SlaveProxy.new(self)
34
+ end
35
+ end
36
+
37
+ def on_slave_block(&block)
38
+ old_slave = current_shard_selection.on_slave?
39
+ begin
40
+ switch_connection(:slave => true)
41
+ rescue ActiveRecord::AdapterNotSpecified => e
42
+ switch_connection(:slave => old_slave)
43
+ logger.warn("Failed to establish shard connection: #{e.message} - defaulting to master")
44
+ end
45
+ with_scope({:find => {:readonly => current_shard_selection.on_slave?}}, :merge, &block)
46
+ ensure
47
+ switch_connection(:slave => old_slave)
48
+ end
49
+
50
+ # Name of the connection pool. Used by ConnectionHandler to retrieve the current connection pool.
51
+ def connection_pool_name # :nodoc:
52
+ current_shard_selection.shard_name(self)
53
+ # if current_shard_selection.any?
54
+ # current_shard_selection.shard_name(name, self)
55
+ # elsif self == ActiveRecord::Base
56
+ # name
57
+ # else
58
+ # superclass.connection_pool_name
59
+ # end
60
+ end
61
+
62
+ private
63
+
64
+ def current_shard_selection
65
+ Thread.current[:shard_selection] ||= ShardSelection.new
66
+ end
67
+
68
+ def switch_connection(options)
69
+ if options.any?
70
+ if options.has_key?(:slave)
71
+ current_shard_selection.on_slave = options[:slave]
72
+ end
73
+
74
+ if options.has_key?(:shard)
75
+ current_shard_selection.shard = options[:shard]
76
+ end
77
+
78
+ establish_shard_connection unless connected_to_shard?
79
+ end
80
+ end
81
+
82
+ def establish_shard_connection
83
+ name = current_shard_selection.shard_name(self)
84
+ spec = configurations[name]
85
+ raise ActiveRecord::AdapterNotSpecified.new("No database defined by #{name} in database.yml") if spec.nil?
86
+
87
+ connection_handler.establish_connection(connection_pool_name, ActiveRecord::Base::ConnectionSpecification.new(spec, "#{spec['adapter']}_connection"))
88
+ end
89
+
90
+ def connected_to_shard?
91
+ connection_handler.connection_pools.has_key?(connection_pool_name)
92
+ end
93
+
94
+ class SlaveProxy
95
+ def initialize(target)
96
+ @target = target
97
+ end
98
+
99
+ def method_missing(method, *args, &block)
100
+ @target.on_slave_block { @target.send(method, *args, &block) }
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,20 @@
1
+ module ActiveRecordShards
2
+ module Model
3
+ def not_sharded
4
+ if self == ActiveRecord::Base || self != base_class
5
+ raise "You should only call not_sharded on direct descendants of ActiveRecord::Base"
6
+ end
7
+ @sharded = false
8
+ end
9
+
10
+ def is_sharded?
11
+ if self == ActiveRecord::Base
12
+ true
13
+ elsif self == base_class
14
+ @sharded != false
15
+ else
16
+ base_class.is_sharded?
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,41 @@
1
+ module ActiveRecordShards
2
+ class ShardSelection
3
+ def initialize
4
+ @on_slave = false
5
+ end
6
+
7
+ def shard
8
+ @shard
9
+ end
10
+
11
+ def shard=(new_shard)
12
+ @shard = new_shard
13
+ end
14
+
15
+ def on_slave?
16
+ @on_slave
17
+ end
18
+
19
+ def on_slave=(new_slave)
20
+ @on_slave = (new_slave == true)
21
+ end
22
+
23
+ def connection_configuration_name
24
+ shard_name#(RAILS_ENV)
25
+ end
26
+
27
+ def shard_name(klass = nil)
28
+ s = "#{RAILS_ENV}"
29
+ if @shard && (klass.nil? || klass.is_sharded?)
30
+ s << '_shard_'
31
+ s << @shard.to_s
32
+ end
33
+ s << "_slave" if @on_slave
34
+ s
35
+ end
36
+
37
+ def any?
38
+ @on_slave || @shard.present?
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,88 @@
1
+ require File.expand_path('helper', File.dirname(__FILE__))
2
+
3
+ class ConfigurationParserTest < ActiveSupport::TestCase
4
+ context "exploding the database.yml" do
5
+ setup do
6
+ @exploded_conf = ActiveRecordShards::ConfigurationParser.explode(YAML::load(IO.read(File.dirname(__FILE__) + '/database_parse_test.yml')))
7
+ end
8
+
9
+ context "main slave" do
10
+ setup { @conf = @exploded_conf['test_slave'] }
11
+ should "be exploded" do
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"
20
+ }, @conf)
21
+ end
22
+ end
23
+
24
+ context "shard a" do
25
+ context "master" do
26
+ setup { @conf = @exploded_conf['test_shard_a'] }
27
+ should "be exploded" do
28
+ 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"
36
+ }, @conf)
37
+ end
38
+ end
39
+
40
+ context "slave" do
41
+ setup { @conf = @exploded_conf['test_shard_a_slave'] }
42
+ should "be exploded" do
43
+ 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"
51
+ }, @conf)
52
+ end
53
+ end
54
+ end
55
+
56
+ context "shard b" do
57
+ context "master" do
58
+ setup { @conf = @exploded_conf['test_shard_b'] }
59
+ should "be exploded" do
60
+ 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"
68
+ }, @conf)
69
+ end
70
+ end
71
+
72
+ context "slave" do
73
+ setup { @conf = @exploded_conf['test_shard_b_slave'] }
74
+ should "be exploded" do
75
+ 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"
83
+ }, @conf)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,167 @@
1
+ require File.expand_path('helper', File.dirname(__FILE__))
2
+
3
+ class ConnectionSwitchenTest < ActiveSupport::TestCase
4
+ context "shard switching" do
5
+ should "only switch connection on sharded models" do
6
+ assert_using_database('ars_test', Ticket)
7
+ assert_using_database('ars_test', Account)
8
+
9
+ ActiveRecord::Base.on_shard(0) do
10
+ assert_using_database('ars_test_shard0', Ticket)
11
+ assert_using_database('ars_test', Account)
12
+ end
13
+ end
14
+
15
+ should "switch to shard and back" do
16
+ assert_using_database('ars_test')
17
+ ActiveRecord::Base.on_slave { assert_using_database('ars_test_slave') }
18
+
19
+ ActiveRecord::Base.on_shard(0) do
20
+ assert_using_database('ars_test_shard0')
21
+ ActiveRecord::Base.on_slave { assert_using_database('ars_test_shard0_slave') }
22
+
23
+ ActiveRecord::Base.on_shard(nil) do
24
+ assert_using_database('ars_test')
25
+ ActiveRecord::Base.on_slave { assert_using_database('ars_test_slave') }
26
+ end
27
+
28
+ assert_using_database('ars_test_shard0')
29
+ ActiveRecord::Base.on_slave { assert_using_database('ars_test_shard0_slave') }
30
+ end
31
+
32
+ assert_using_database('ars_test')
33
+ ActiveRecord::Base.on_slave { assert_using_database('ars_test_slave') }
34
+ end
35
+ end
36
+
37
+ context "slave driving" do
38
+ context "without slave configuration" do
39
+
40
+ setup do
41
+ ActiveRecord::Base.configurations.delete('test_slave')
42
+ ActiveRecord::Base.connection_handler.connection_pools.clear
43
+ ActiveRecord::Base.establish_connection('test')
44
+ end
45
+
46
+ should "default to the master database" do
47
+ Account.create!
48
+
49
+ ActiveRecord::Base.on_slave { assert_using_master_db }
50
+ Account.on_slave { assert_using_master_db }
51
+ Ticket.on_slave { assert_using_master_db }
52
+ end
53
+
54
+ should "successfully execute queries" do
55
+ Account.create!
56
+ assert_using_master_db
57
+
58
+ assert_equal Account.count, ActiveRecord::Base.on_slave { Account.count }
59
+ assert_equal Account.count, Account.on_slave { Account.count }
60
+ end
61
+
62
+ end
63
+
64
+ context "with slave configuration" do
65
+
66
+ should "successfully execute queries" do
67
+ assert_using_master_db
68
+ Account.create!
69
+
70
+ assert_equal(1, Account.count)
71
+ assert_equal(0, ActiveRecord::Base.on_slave { Account.count })
72
+ end
73
+
74
+ should "support global on_slave blocks" do
75
+ assert_using_master_db
76
+ assert_using_master_db
77
+
78
+ ActiveRecord::Base.on_slave do
79
+ assert_using_slave_db
80
+ assert_using_slave_db
81
+ end
82
+
83
+ assert_using_master_db
84
+ assert_using_master_db
85
+ end
86
+
87
+ should "support conditional methods" do
88
+ assert_using_master_db
89
+
90
+ Account.on_slave_if(true) do
91
+ assert_using_slave_db
92
+ end
93
+
94
+ assert_using_master_db
95
+
96
+ Account.on_slave_if(false) do
97
+ assert_using_master_db
98
+ end
99
+
100
+ Account.on_slave_unless(true) do
101
+ assert_using_master_db
102
+ end
103
+
104
+ Account.on_slave_unless(false) do
105
+ assert_using_slave_db
106
+ end
107
+
108
+ end
109
+
110
+ context "a model loaded with the slave" do
111
+ setup do
112
+ Account.connection.execute("INSERT INTO accounts (id, name, created_at, updated_at) VALUES(1000, 'master_name', '2009-12-04 20:18:48', '2009-12-04 20:18:48')")
113
+ assert(Account.find(1000))
114
+ assert_equal('master_name', Account.find(1000).name)
115
+
116
+ Account.on_slave.connection.execute("INSERT INTO accounts (id, name, created_at, updated_at) VALUES(1000, 'slave_name', '2009-12-04 20:18:48', '2009-12-04 20:18:48')")
117
+
118
+ @model = Account.on_slave.find(1000)
119
+ assert(@model)
120
+ assert_equal('slave_name', @model.name)
121
+ end
122
+
123
+ should "read from master on reload" do
124
+ @model.reload
125
+ assert_equal('master_name', @model.name)
126
+ end
127
+
128
+ should "be marked as read only" do
129
+ assert(@model.readonly?)
130
+ end
131
+ end
132
+
133
+ context "a model loaded with the master" do
134
+ setup do
135
+ Account.connection.execute("INSERT INTO accounts (id, name, created_at, updated_at) VALUES(1000, 'master_name', '2009-12-04 20:18:48', '2009-12-04 20:18:48')")
136
+ @model = Account.first
137
+ assert(@model)
138
+ assert_equal('master_name', @model.name)
139
+ end
140
+
141
+ should "not be marked as read only" do
142
+ assert(!@model.readonly?)
143
+ end
144
+ end
145
+ end
146
+
147
+ context "slave proxy" do
148
+ should "successfully execute queries" do
149
+ assert_using_master_db
150
+ Account.create!
151
+
152
+ assert_not_equal Account.count, Account.on_slave.count
153
+ end
154
+
155
+ should "work on association collections" do
156
+ assert_using_master_db
157
+ account = Account.create!
158
+
159
+ Ticket.connection.expects(:select_all).with("SELECT * FROM `tickets` WHERE (`tickets`.account_id = #{account.id}) LIMIT 1", anything).returns([])
160
+ Ticket.on_slave.connection.expects(:select_all).with("SELECT * FROM `tickets` WHERE (`tickets`.account_id = #{account.id}) LIMIT 1", anything).returns([])
161
+
162
+ account.tickets.first
163
+ account.tickets.on_slave.first
164
+ end
165
+ end
166
+ end
167
+ end