active_record_shards 2.0.0.beta1

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.
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