ar-octopus 0.8.1 → 0.8.2

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.
Files changed (35) hide show
  1. checksums.yaml +8 -8
  2. data/README.mkdn +80 -55
  3. data/lib/octopus.rb +20 -6
  4. data/lib/octopus/{rails3/abstract_adapter.rb → abstract_adapter.rb} +1 -4
  5. data/lib/octopus/association.rb +5 -99
  6. data/lib/octopus/association_shard_tracking.rb +105 -0
  7. data/lib/octopus/collection_association.rb +9 -0
  8. data/lib/octopus/collection_proxy.rb +14 -0
  9. data/lib/octopus/has_and_belongs_to_many_association.rb +2 -12
  10. data/lib/octopus/load_balancing.rb +3 -0
  11. data/lib/octopus/load_balancing/round_robin.rb +15 -0
  12. data/lib/octopus/{rails3/log_subscriber.rb → log_subscriber.rb} +0 -0
  13. data/lib/octopus/model.rb +74 -85
  14. data/lib/octopus/{rails3/persistence.rb → persistence.rb} +0 -0
  15. data/lib/octopus/proxy.rb +166 -29
  16. data/lib/octopus/relation_proxy.rb +39 -0
  17. data/lib/octopus/scope_proxy.rb +7 -10
  18. data/lib/octopus/shard_tracking.rb +45 -0
  19. data/lib/octopus/shard_tracking/attribute.rb +24 -0
  20. data/lib/octopus/shard_tracking/dynamic.rb +7 -0
  21. data/lib/octopus/singular_association.rb +7 -0
  22. data/lib/octopus/slave_group.rb +11 -0
  23. data/lib/octopus/version.rb +1 -1
  24. data/spec/config/shards.yml +53 -0
  25. data/spec/octopus/{association_spec.rb → association_shard_tracking_spec.rb} +1 -1
  26. data/spec/octopus/collection_proxy_spec.rb +15 -0
  27. data/spec/octopus/model_spec.rb +2 -2
  28. data/spec/octopus/octopus_spec.rb +34 -0
  29. data/spec/octopus/relation_proxy_spec.rb +77 -0
  30. data/spec/octopus/replicated_slave_grouped_spec.rb +64 -0
  31. data/spec/octopus/sharded_replicated_slave_grouped_spec.rb +55 -0
  32. data/spec/support/octopus_helper.rb +1 -0
  33. metadata +26 -9
  34. data/lib/octopus/association_collection.rb +0 -49
  35. data/lib/octopus/rails3/singular_association.rb +0 -34
@@ -0,0 +1,9 @@
1
+ module Octopus::CollectionAssociation
2
+ def self.included(base)
3
+ base.sharded_methods :reader, :writer, :ids_reader, :ids_writer, :create, :create!,
4
+ :build, :any?, :count, :empty?, :first, :include?, :last, :length,
5
+ :load_target, :many?, :reload, :size, :select, :uniq
6
+ end
7
+ end
8
+
9
+ ActiveRecord::Associations::CollectionAssociation.send(:include, Octopus::CollectionAssociation)
@@ -0,0 +1,14 @@
1
+ module Octopus::CollectionProxy
2
+ def self.included(base)
3
+ base.send(:include, Octopus::ShardTracking::Dynamic)
4
+ base.sharded_methods :any?, :build, :count, :create, :create!, :concat, :delete, :delete_all,
5
+ :destroy, :destroy_all, :empty?, :find, :first, :include?, :last, :length,
6
+ :many?, :pluck, :replace, :select, :size, :sum, :to_a, :uniq
7
+ end
8
+
9
+ def current_shard
10
+ @association.owner.current_shard
11
+ end
12
+ end
13
+
14
+ ActiveRecord::Associations::CollectionProxy.send(:include, Octopus::CollectionProxy)
@@ -1,17 +1,7 @@
1
1
  module Octopus::HasAndBelongsToManyAssociation
2
2
  def self.included(base)
3
- base.instance_eval do
4
- alias_method_chain :insert_record, :octopus
5
- end
6
- end
7
-
8
- def insert_record_with_octopus(record, force = true, validate = true)
9
- if should_wrap_the_connection?
10
- Octopus.using(@owner.current_shard) { insert_record_without_octopus(record, force, validate) }
11
- else
12
- insert_record_without_octopus(record, force, validate)
13
- end
3
+ base.sharded_methods :insert_record
14
4
  end
15
5
  end
16
6
 
17
- ActiveRecord::Associations::HasAndBelongsToManyAssociation.send(:include, Octopus::HasAndBelongsToManyAssociation)
7
+ ActiveRecord::Associations::HasAndBelongsToManyAssociation.send(:include, Octopus::HasAndBelongsToManyAssociation)
@@ -0,0 +1,3 @@
1
+ module Octopus::LoadBalancing
2
+
3
+ end
@@ -0,0 +1,15 @@
1
+ require 'octopus/load_balancing'
2
+
3
+ # The round-robin load balancing of slaves belonging to the same shard.
4
+ # It is a pool that contains slaves which queries are distributed to.
5
+ class Octopus::LoadBalancing::RoundRobin
6
+ def initialize(slaves_list)
7
+ @slaves_list = slaves_list
8
+ @slave_index = 0
9
+ end
10
+
11
+ # Returns the next available slave in the pool
12
+ def next
13
+ @slaves_list[@slave_index = (@slave_index + 1) % @slaves_list.length]
14
+ end
15
+ end
data/lib/octopus/model.rb CHANGED
@@ -2,10 +2,9 @@ require 'active_support/deprecation'
2
2
 
3
3
  module Octopus::Model
4
4
  def self.extended(base)
5
+ base.send(:include, Octopus::ShardTracking::Attribute)
5
6
  base.send(:include, InstanceMethods)
6
7
  base.extend(ClassMethods)
7
- base.hijack_connection()
8
- base.hijack_initializer()
9
8
  end
10
9
 
11
10
  module SharedMethods
@@ -28,82 +27,6 @@ module Octopus::Model
28
27
  self
29
28
  end
30
29
  end
31
-
32
- def hijack_initializer()
33
- attr_accessor :current_shard
34
- around_save :run_on_shard
35
-
36
- def set_current_shard
37
- return unless Octopus.enabled?
38
-
39
- if new_record? || self.class.connection_proxy.block
40
- self.current_shard = self.class.connection_proxy.current_shard
41
- else
42
- self.current_shard = self.class.connection_proxy.last_current_shard || self.class.connection_proxy.current_shard
43
- end
44
- end
45
-
46
- after_initialize :set_current_shard
47
- end
48
-
49
- def hijack_connection()
50
- def self.should_use_normal_connection?
51
- !Octopus.enabled? || self.custom_octopus_connection
52
- end
53
-
54
- def self.connection_proxy
55
- @@connection_proxy ||= Octopus::Proxy.new
56
- end
57
-
58
- def self.connection_with_octopus
59
- if should_use_normal_connection?
60
- connection_without_octopus
61
- else
62
- self.connection_proxy.current_model = self
63
- self.connection_proxy
64
- end
65
- end
66
-
67
- def self.connection_pool_with_octopus
68
- if should_use_normal_connection?
69
- connection_pool_without_octopus
70
- else
71
- connection_proxy.connection_pool
72
- end
73
- end
74
-
75
- def self.clear_active_connections_with_octopus!
76
- if should_use_normal_connection?
77
- clear_active_connections_without_octopus!
78
- else
79
- connection_proxy.clear_active_connections!
80
- end
81
- end
82
-
83
- def self.clear_all_connections_with_octopus!
84
- if should_use_normal_connection?
85
- clear_all_connections_without_octopus!
86
- else
87
- connection_proxy.clear_all_connections!
88
- end
89
- end
90
-
91
- def self.connected_with_octopus?
92
- if should_use_normal_connection?
93
- connected_without_octopus?
94
- else
95
- connection_proxy.connected?
96
- end
97
- end
98
-
99
- class << self
100
- alias_method_chain :connection, :octopus
101
- alias_method_chain :connection_pool, :octopus
102
- alias_method_chain :clear_all_connections!, :octopus
103
- alias_method_chain :clear_active_connections!, :octopus
104
- alias_method_chain :connected?, :octopus
105
- end
106
- end
107
30
  end
108
31
 
109
32
  module InstanceMethods
@@ -116,18 +39,20 @@ module Octopus::Model
116
39
  base.send(:alias_method_chain, :perform_validations, :octopus)
117
40
  end
118
41
 
119
- def should_set_current_shard?
120
- self.respond_to?(:current_shard) && !self.current_shard.nil?
121
- end
42
+ def set_current_shard
43
+ return unless Octopus.enabled?
122
44
 
123
- def run_on_shard(&block)
124
- if self.current_shard
125
- self.class.connection_proxy.run_queries_on_shard(self.current_shard, &block)
45
+ if new_record? || self.class.connection_proxy.block
46
+ self.current_shard = self.class.connection_proxy.current_shard
126
47
  else
127
- yield
48
+ self.current_shard = self.class.connection_proxy.last_current_shard || self.class.connection_proxy.current_shard
128
49
  end
129
50
  end
130
51
 
52
+ def should_set_current_shard?
53
+ self.respond_to?(:current_shard) && !self.current_shard.nil?
54
+ end
55
+
131
56
  def equality_with_octopus(comparison_object)
132
57
  equality_without_octopus(comparison_object) && comparison_object.current_shard == current_shard
133
58
  end
@@ -161,10 +86,19 @@ module Octopus::Model
161
86
  end
162
87
 
163
88
  def hijack_methods
89
+ around_save :run_on_shard
90
+ after_initialize :set_current_shard
91
+
164
92
  class << self
165
93
  attr_accessor :custom_octopus_connection
166
94
  attr_accessor :custom_octopus_table_name
167
95
 
96
+ alias_method_chain :connection, :octopus
97
+ alias_method_chain :connection_pool, :octopus
98
+ alias_method_chain :clear_all_connections!, :octopus
99
+ alias_method_chain :clear_active_connections!, :octopus
100
+ alias_method_chain :connected?, :octopus
101
+
168
102
  if Octopus.rails3?
169
103
  alias_method_chain(:set_table_name, :octopus)
170
104
  end
@@ -176,6 +110,61 @@ module Octopus::Model
176
110
  end
177
111
  end
178
112
 
113
+ def connection_proxy
114
+ cached = ActiveRecord::Base.class_variable_get :@@connection_proxy rescue nil
115
+ cached ||
116
+ begin
117
+ p = Octopus::Proxy.new
118
+ ActiveRecord::Base.class_variable_set :@@connection_proxy, p
119
+ p
120
+ end
121
+ end
122
+
123
+ def should_use_normal_connection?
124
+ !Octopus.enabled? || custom_octopus_connection
125
+ end
126
+
127
+ def connection_with_octopus
128
+ if should_use_normal_connection?
129
+ connection_without_octopus
130
+ else
131
+ connection_proxy.current_model = self
132
+ connection_proxy
133
+ end
134
+ end
135
+
136
+ def connection_pool_with_octopus
137
+ if should_use_normal_connection?
138
+ connection_pool_without_octopus
139
+ else
140
+ connection_proxy.connection_pool
141
+ end
142
+ end
143
+
144
+ def clear_active_connections_with_octopus!
145
+ if should_use_normal_connection?
146
+ clear_active_connections_without_octopus!
147
+ else
148
+ connection_proxy.clear_active_connections!
149
+ end
150
+ end
151
+
152
+ def clear_all_connections_with_octopus!
153
+ if should_use_normal_connection?
154
+ clear_all_connections_without_octopus!
155
+ else
156
+ connection_proxy.clear_all_connections!
157
+ end
158
+ end
159
+
160
+ def connected_with_octopus?
161
+ if should_use_normal_connection?
162
+ connected_without_octopus?
163
+ else
164
+ connection_proxy.connected?
165
+ end
166
+ end
167
+
179
168
  def set_table_name_with_octopus(value = nil, &block)
180
169
  self.custom_octopus_table_name = true
181
170
  set_table_name_without_octopus(value, &block)
data/lib/octopus/proxy.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  require "set"
2
+ require 'octopus/slave_group'
3
+ require 'octopus/load_balancing/round_robin'
2
4
 
3
5
  class Octopus::Proxy
4
- attr_accessor :config
6
+ attr_accessor :config, :sharded
5
7
 
6
8
  def initialize(config = Octopus.config)
7
9
  initialize_shards(config)
@@ -10,18 +12,20 @@ class Octopus::Proxy
10
12
 
11
13
  def initialize_shards(config)
12
14
  @shards = HashWithIndifferentAccess.new
15
+ @shards_slave_groups = HashWithIndifferentAccess.new
16
+ @slave_groups = HashWithIndifferentAccess.new
13
17
  @groups = {}
14
18
  @adapters = Set.new
15
19
  @config = ActiveRecord::Base.connection_pool_without_octopus.connection.instance_variable_get(:@config)
16
20
 
17
21
  if !config.nil?
18
22
  @entire_sharded = config['entire_sharded']
19
- shards_config = config[Octopus.rails_env()]
23
+ @shards_config = config[Octopus.rails_env()]
20
24
  end
21
25
 
22
- shards_config ||= []
26
+ @shards_config ||= []
23
27
 
24
- shards_config.each do |key, value|
28
+ @shards_config.each do |key, value|
25
29
  if value.is_a?(String)
26
30
  value = resolve_string_connection(value).merge(:octopus_shard => key)
27
31
  initialize_adapter(value['adapter'])
@@ -30,6 +34,24 @@ class Octopus::Proxy
30
34
  value.merge!(:octopus_shard => key)
31
35
  initialize_adapter(value['adapter'])
32
36
  @shards[key.to_sym] = connection_pool_for(value, "#{value['adapter']}_connection")
37
+
38
+ slave_group_configs = value.select do |k,v|
39
+ structurally_slave_group? v
40
+ end
41
+
42
+ if slave_group_configs.present?
43
+ slave_groups = HashWithIndifferentAccess.new
44
+ slave_group_configs.each do |slave_group_name, slave_configs|
45
+ slaves = HashWithIndifferentAccess.new
46
+ slave_configs.each do |slave_name, slave_config|
47
+ @shards[slave_name.to_sym] = connection_pool_for(slave_config, "#{value['adapter']}_connection")
48
+ slaves[slave_name.to_sym] = slave_name.to_sym
49
+ end
50
+ slave_groups[slave_group_name.to_sym] = Octopus::SlaveGroup.new(slaves)
51
+ end
52
+ @shards_slave_groups[key.to_sym] = slave_groups
53
+ @sharded = true
54
+ end
33
55
  elsif value.is_a?(Hash)
34
56
  @groups[key.to_s] = []
35
57
 
@@ -42,6 +64,11 @@ class Octopus::Proxy
42
64
  @shards[k.to_sym] = connection_pool_for(config_with_octopus_shard, "#{v['adapter']}_connection")
43
65
  @groups[key.to_s] << k.to_sym
44
66
  end
67
+
68
+ if structurally_slave_group? value
69
+ slaves = Hash[@groups[key.to_s].map { |v| [v, v ] }]
70
+ @slave_groups[key.to_sym] = Octopus::SlaveGroup.new(slaves)
71
+ end
45
72
  end
46
73
  end
47
74
 
@@ -58,7 +85,7 @@ class Octopus::Proxy
58
85
 
59
86
  @slaves_list = @shards.keys.map {|sym| sym.to_s}.sort
60
87
  @slaves_list.delete('master')
61
- @slave_index = 0
88
+ @slaves_load_balancer = Octopus::LoadBalancing::RoundRobin.new(@slaves_list)
62
89
  end
63
90
 
64
91
  def current_model
@@ -74,8 +101,29 @@ class Octopus::Proxy
74
101
  end
75
102
 
76
103
  def current_shard=(shard_symbol)
104
+ self.current_slave_group = nil
77
105
  if shard_symbol.is_a?(Array)
78
106
  shard_symbol.each {|symbol| raise "Nonexistent Shard Name: #{symbol}" if @shards[symbol].nil? }
107
+ elsif shard_symbol.is_a?(Hash)
108
+ hash = shard_symbol
109
+ shard_symbol = hash[:shard]
110
+ slave_group_symbol = hash[:slave_group]
111
+
112
+ if shard_symbol.nil? && slave_group_symbol.nil?
113
+ raise "Neither shard or slave group must be specified"
114
+ end
115
+
116
+ if shard_symbol.present?
117
+ raise "Nonexistent Shard Name: #{shard_symbol}" if @shards[shard_symbol].nil?
118
+ end
119
+
120
+ if slave_group_symbol.present?
121
+ if (@shards_slave_groups.try(:[], shard_symbol).present? && @shards_slave_groups[shard_symbol][slave_group_symbol].nil?) ||
122
+ (@shards_slave_groups.try(:[], shard_symbol).nil? && @slave_groups[slave_group_symbol].nil?)
123
+ raise "Nonexistent Slave Group Name: #{slave_group_symbol} in shards config: #{@shards_config.inspect}"
124
+ end
125
+ self.current_slave_group = slave_group_symbol
126
+ end
79
127
  else
80
128
  raise "Nonexistent Shard Name: #{shard_symbol}" if @shards[shard_symbol].nil?
81
129
  end
@@ -96,6 +144,14 @@ class Octopus::Proxy
96
144
  Thread.current["octopus.current_group"] = group_symbol
97
145
  end
98
146
 
147
+ def current_slave_group
148
+ Thread.current["octopus.current_slave_group"]
149
+ end
150
+
151
+ def current_slave_group=(slave_group_symbol)
152
+ Thread.current["octopus.current_slave_group"] = slave_group_symbol
153
+ end
154
+
99
155
  def block
100
156
  Thread.current["octopus.block"]
101
157
  end
@@ -112,6 +168,10 @@ class Octopus::Proxy
112
168
  Thread.current["octopus.last_current_shard"] = last_current_shard
113
169
  end
114
170
 
171
+ def fully_replicated?
172
+ @fully_replicated || Thread.current["octopus.fully_replicated"]
173
+ end
174
+
115
175
  # Public: Whether or not a group exists with the given name converted to a
116
176
  # string.
117
177
  #
@@ -157,16 +217,10 @@ class Octopus::Proxy
157
217
  end
158
218
 
159
219
  def run_queries_on_shard(shard, &block)
160
- older_shard = self.current_shard
161
- last_block = self.block
162
-
163
- begin
164
- self.block = true
165
- self.current_shard = shard
166
- yield
167
- ensure
168
- self.block = last_block || false
169
- self.current_shard = older_shard
220
+ keeping_connection_proxy do
221
+ using_shard(shard) do
222
+ yield
223
+ end
170
224
  end
171
225
  end
172
226
 
@@ -176,7 +230,7 @@ class Octopus::Proxy
176
230
  end
177
231
  end
178
232
 
179
- def clean_proxy()
233
+ def clean_connection_proxy()
180
234
  self.current_shard = :master
181
235
  self.current_group = nil
182
236
  self.block = false
@@ -189,7 +243,8 @@ class Octopus::Proxy
189
243
  end
190
244
 
191
245
  def transaction(options = {}, &block)
192
- if @replicated && (current_model.replicated || @fully_replicated)
246
+ replicated = @replicated && (current_model.replicated || fully_replicated?)
247
+ if !sharded && replicated
193
248
  self.run_queries_on_shard(:master) do
194
249
  select_connection.transaction(options, &block)
195
250
  end
@@ -199,11 +254,15 @@ class Octopus::Proxy
199
254
  end
200
255
 
201
256
  def method_missing(method, *args, &block)
202
- if should_clean_connection?(method)
257
+ if should_clean_connection_proxy?(method)
203
258
  conn = select_connection()
204
259
  self.last_current_shard = self.current_shard
205
- clean_proxy()
260
+ clean_connection_proxy()
206
261
  conn.send(method, *args, &block)
262
+ elsif should_send_queries_to_shard_slave_group?(method)
263
+ send_queries_to_shard_slave_group(method, *args, &block)
264
+ elsif should_send_queries_to_slave_group?(method)
265
+ send_queries_to_slave_group(method, *args, &block)
207
266
  elsif should_send_queries_to_replicated_databases?(method)
208
267
  send_queries_to_selected_slave(method, *args, &block)
209
268
  else
@@ -244,6 +303,22 @@ class Octopus::Proxy
244
303
  @shards.any? { |k, v| v.connected? }
245
304
  end
246
305
 
306
+ def should_send_queries_to_shard_slave_group?(method)
307
+ should_use_slaves_for_method?(method) && @shards_slave_groups.try(:[], current_shard).try(:[], current_slave_group).present?
308
+ end
309
+
310
+ def send_queries_to_shard_slave_group(method, *args, &block)
311
+ send_queries_to_balancer(@shards_slave_groups[current_shard][current_slave_group], method, *args, &block)
312
+ end
313
+
314
+ def should_send_queries_to_slave_group?(method)
315
+ should_use_slaves_for_method?(method) && @slave_groups.try(:[], current_slave_group).present?
316
+ end
317
+
318
+ def send_queries_to_slave_group(method, *args, &block)
319
+ send_queries_to_balancer(@slave_groups[current_slave_group], method, *args, &block)
320
+ end
321
+
247
322
  protected
248
323
 
249
324
  def connection_pool_for(adapter, config)
@@ -274,27 +349,89 @@ class Octopus::Proxy
274
349
  resolver.spec.config.stringify_keys
275
350
  end
276
351
 
277
- def should_clean_connection?(method)
352
+ def should_clean_connection_proxy?(method)
278
353
  method.to_s =~ /insert|select|execute/ && !@replicated && !self.block
279
354
  end
280
355
 
356
+ # Try to use slaves if and only if `replicated: true` is specified in `shards.yml` and no slaves groups are defined
281
357
  def should_send_queries_to_replicated_databases?(method)
282
- @replicated && method.to_s =~ /select/ && !self.block
358
+ @replicated && method.to_s =~ /select/ && !self.block && !slaves_grouped?
283
359
  end
284
360
 
285
361
  def send_queries_to_selected_slave(method, *args, &block)
286
- old_shard = self.current_shard
362
+ if current_model.replicated || fully_replicated?
363
+ selected_slave = @slaves_load_balancer.next
364
+ else
365
+ selected_slave = :master
366
+ end
287
367
 
288
- begin
289
- if current_model.replicated || @fully_replicated
290
- self.current_shard = @slaves_list[@slave_index = (@slave_index + 1) % @slaves_list.length]
291
- else
292
- self.current_shard = :master
293
- end
368
+ send_queries_to_slave(selected_slave, method, *args, &block)
369
+ end
370
+
371
+ # We should use slaves if and only if its safe to do so.
372
+ #
373
+ # We can safely use slaves when:
374
+ # (1) `replicated: true` is specified in `shards.yml`
375
+ # (2) The current model is `replicated()`, or `fully_replicated: true` is specified in `shards.yml` which means that
376
+ # all the model is `replicated()`
377
+ # (3) It's a SELECT query
378
+ # while ensuring that we revert `current_shard` from the selected slave to the (shard's) master
379
+ # not to make queries other than SELECT leak to the slave.
380
+ def should_use_slaves_for_method?(method)
381
+ @replicated && (current_model.replicated || fully_replicated?) && method.to_s =~ /select/
382
+ end
383
+
384
+ def slaves_grouped?
385
+ @slave_groups.present?
386
+ end
294
387
 
388
+ # Temporarily switch `current_shard` to the next slave in a slave group and send queries to it
389
+ # while preserving `current_shard`
390
+ def send_queries_to_balancer(balancer, method, *args, &block)
391
+ send_queries_to_slave(balancer.next, method, *args, &block)
392
+ end
393
+
394
+ # Temporarily switch `current_shard` to the specified slave and send queries to it
395
+ # while preserving `current_shard`
396
+ def send_queries_to_slave(slave, method, *args, &block)
397
+ using_shard(slave) do
295
398
  select_connection.send(method, *args, &block)
399
+ end
400
+ end
401
+
402
+ # Temporarily block cleaning connection proxy and run the block
403
+ #
404
+ # @see Octopus::Proxy#should_clean_connection?
405
+ # @see Octopus::Proxy#clean_connection_proxy
406
+ def keeping_connection_proxy(&block)
407
+ last_block = self.block
408
+
409
+ begin
410
+ self.block = true
411
+ yield
412
+ ensure
413
+ self.block = last_block || false
414
+ end
415
+ end
416
+
417
+ # Temporarily switch `current_shard` and run the block
418
+ def using_shard(shard, &block)
419
+ older_shard = self.current_shard
420
+
421
+ begin
422
+ self.current_shard = shard
423
+ yield
296
424
  ensure
297
- self.current_shard = old_shard
425
+ self.current_shard = older_shard
298
426
  end
299
427
  end
428
+
429
+ def structurally_slave?(config)
430
+ config.is_a?(Hash) && config.key?("adapter")
431
+ end
432
+
433
+ def structurally_slave_group?(config)
434
+ config.is_a?(Hash) && config.values.any? {|v| structurally_slave? v }
435
+ end
436
+
300
437
  end