switchman 3.0.2 → 3.1.0

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +1 -1
  3. data/db/migrate/20180828183945_add_default_shard_index.rb +1 -1
  4. data/db/migrate/20180828192111_add_timestamps_to_shards.rb +1 -1
  5. data/lib/switchman/action_controller/caching.rb +2 -2
  6. data/lib/switchman/active_record/abstract_adapter.rb +2 -13
  7. data/lib/switchman/active_record/associations.rb +223 -0
  8. data/lib/switchman/active_record/attribute_methods.rb +144 -63
  9. data/lib/switchman/active_record/base.rb +100 -43
  10. data/lib/switchman/active_record/calculations.rb +12 -5
  11. data/lib/switchman/active_record/connection_pool.rb +9 -31
  12. data/lib/switchman/active_record/database_configurations.rb +18 -2
  13. data/lib/switchman/active_record/finder_methods.rb +2 -2
  14. data/lib/switchman/active_record/migration.rb +7 -4
  15. data/lib/switchman/active_record/model_schema.rb +1 -1
  16. data/lib/switchman/active_record/persistence.rb +7 -2
  17. data/lib/switchman/active_record/postgresql_adapter.rb +6 -2
  18. data/lib/switchman/active_record/predicate_builder.rb +1 -1
  19. data/lib/switchman/active_record/query_methods.rb +27 -14
  20. data/lib/switchman/active_record/reflection.rb +1 -1
  21. data/lib/switchman/active_record/relation.rb +25 -24
  22. data/lib/switchman/active_record/statement_cache.rb +2 -2
  23. data/lib/switchman/active_record/table_definition.rb +1 -1
  24. data/lib/switchman/active_record/test_fixtures.rb +43 -0
  25. data/lib/switchman/active_support/cache.rb +16 -0
  26. data/lib/switchman/arel.rb +28 -6
  27. data/lib/switchman/database_server.rb +71 -65
  28. data/lib/switchman/default_shard.rb +0 -2
  29. data/lib/switchman/engine.rb +67 -125
  30. data/lib/switchman/errors.rb +4 -2
  31. data/lib/switchman/guard_rail/relation.rb +6 -9
  32. data/lib/switchman/guard_rail.rb +5 -0
  33. data/lib/switchman/parallel.rb +68 -0
  34. data/lib/switchman/r_spec_helper.rb +5 -17
  35. data/lib/switchman/rails.rb +1 -4
  36. data/{app/models → lib}/switchman/shard.rb +61 -188
  37. data/lib/switchman/sharded_instrumenter.rb +1 -1
  38. data/lib/switchman/standard_error.rb +11 -12
  39. data/{app/models → lib}/switchman/unsharded_record.rb +1 -1
  40. data/lib/switchman/version.rb +1 -1
  41. data/lib/switchman.rb +22 -2
  42. data/lib/tasks/switchman.rake +24 -13
  43. metadata +24 -22
  44. data/lib/switchman/active_record/association.rb +0 -206
  45. data/lib/switchman/open4.rb +0 -80
@@ -9,13 +9,13 @@ module Switchman
9
9
 
10
10
  def initialize(*, **)
11
11
  super
12
- self.shard_value = Shard.current(klass ? klass.connection_classes : :primary) unless shard_value
12
+ self.shard_value = Shard.current(klass ? klass.connection_class_for_self : :primary) unless shard_value
13
13
  self.shard_source_value = :implicit unless shard_source_value
14
14
  end
15
15
 
16
16
  def clone
17
17
  result = super
18
- result.shard_value = Shard.current(klass ? klass.connection_classes : :primary) unless shard_value
18
+ result.shard_value = Shard.current(klass ? klass.connection_class_for_self : :primary) unless shard_value
19
19
  result
20
20
  end
21
21
 
@@ -29,41 +29,38 @@ module Switchman
29
29
  end
30
30
 
31
31
  def new(*, &block)
32
- primary_shard.activate(klass.connection_classes) { super }
32
+ primary_shard.activate(klass.connection_class_for_self) { super }
33
33
  end
34
34
 
35
35
  def create(*, &block)
36
- primary_shard.activate(klass.connection_classes) { super }
36
+ primary_shard.activate(klass.connection_class_for_self) { super }
37
37
  end
38
38
 
39
39
  def create!(*, &block)
40
- primary_shard.activate(klass.connection_classes) { super }
40
+ primary_shard.activate(klass.connection_class_for_self) { super }
41
41
  end
42
42
 
43
43
  def to_sql
44
- primary_shard.activate(klass.connection_classes) { super }
44
+ primary_shard.activate(klass.connection_class_for_self) { super }
45
45
  end
46
46
 
47
47
  def explain
48
48
  activate { |relation| relation.call_super(:explain, Relation) }
49
49
  end
50
50
 
51
- def records
52
- return @records if loaded?
53
-
54
- results = activate { |relation| relation.call_super(:records, Relation) }
55
- case shard_value
56
- when Array, ::ActiveRecord::Relation, ::ActiveRecord::Base
57
- @records = results
51
+ def load(&block)
52
+ if !loaded? || (::Rails.version >= '7.0' && scheduled?)
53
+ @records = activate { |relation| relation.send(:exec_queries, &block) }
58
54
  @loaded = true
59
55
  end
60
- results
56
+
57
+ self
61
58
  end
62
59
 
63
60
  %I[update_all delete_all].each do |method|
64
61
  class_eval <<-RUBY, __FILE__, __LINE__ + 1
65
62
  def #{method}(*args)
66
- result = self.activate { |relation| relation.call_super(#{method.inspect}, Relation, *args) }
63
+ result = self.activate(unordered: true) { |relation| relation.call_super(#{method.inspect}, Relation, *args) }
67
64
  result = result.sum if result.is_a?(Array)
68
65
  result
69
66
  end
@@ -94,7 +91,7 @@ module Switchman
94
91
 
95
92
  while ids.first.present?
96
93
  ids.map!(&:to_i) if is_integer
97
- ids << ids.first + batch_size if loose_mode
94
+ ids << (ids.first + batch_size) if loose_mode
98
95
 
99
96
  yield(*ids)
100
97
  last_value = ids.last
@@ -103,28 +100,28 @@ module Switchman
103
100
  end
104
101
  end
105
102
 
106
- def activate(&block)
103
+ def activate(unordered: false, &block)
107
104
  shards = all_shards
108
105
  if Array === shards && shards.length == 1
109
- if shards.first == DefaultShard || shards.first == Shard.current(klass.connection_classes)
106
+ if shards.first == DefaultShard || shards.first == Shard.current(klass.connection_class_for_self)
110
107
  yield(self, shards.first)
111
108
  else
112
- shards.first.activate(klass.connection_classes) { yield(self, shards.first) }
109
+ shards.first.activate(klass.connection_class_for_self) { yield(self, shards.first) }
113
110
  end
114
111
  else
115
112
  result_count = 0
116
113
  can_order = false
117
- result = Shard.with_each_shard(shards, [klass.connection_classes]) do
114
+ result = Shard.with_each_shard(shards, [klass.connection_class_for_self]) do
118
115
  # don't even query other shards if we're already past the limit
119
116
  next if limit_value && result_count >= limit_value && order_values.empty?
120
117
 
121
- relation = shard(Shard.current(klass.connection_classes), :to_a)
118
+ relation = shard(Shard.current(klass.connection_class_for_self), :to_a)
122
119
  # do a minimal query if possible
123
120
  relation = relation.limit(limit_value - result_count) if limit_value && !result_count.zero? && order_values.empty?
124
121
 
125
122
  shard_results = relation.activate(&block)
126
123
 
127
- if shard_results.present?
124
+ if shard_results.present? && !unordered
128
125
  can_order ||= can_order_cross_shard_results? unless order_values.empty?
129
126
  raise OrderOnMultiShardQuery if !can_order && !order_values.empty? && result_count.positive?
130
127
 
@@ -148,8 +145,12 @@ module Switchman
148
145
  results.sort! do |l, r|
149
146
  result = 0
150
147
  order_values.each do |ov|
151
- a = l.attribute(ov.expr.name)
152
- b = r.attribute(ov.expr.name)
148
+ if l.is_a?(::ActiveRecord::Base)
149
+ a = l.attribute(ov.expr.name)
150
+ b = r.attribute(ov.expr.name)
151
+ else
152
+ a, b = l, r
153
+ end
153
154
  next if a == b
154
155
 
155
156
  if a.nil? || b.nil?
@@ -33,12 +33,12 @@ module Switchman
33
33
  primary_value = params[primary_index]
34
34
  target_shard = Shard.local_id_for(primary_value)[1]
35
35
  end
36
- current_shard = Shard.current(klass.connection_classes)
36
+ current_shard = Shard.current(klass.connection_class_for_self)
37
37
  target_shard ||= current_shard
38
38
 
39
39
  bind_values = bind_map.bind(params, current_shard, target_shard)
40
40
 
41
- target_shard.activate(klass.connection_classes) do
41
+ target_shard.activate(klass.connection_class_for_self) do
42
42
  sql = qualified_query_builder(target_shard, klass).sql_for(bind_values, connection)
43
43
  klass.find_by_sql(sql, bind_values)
44
44
  end
@@ -4,7 +4,7 @@ module Switchman
4
4
  module ActiveRecord
5
5
  module TableDefinition
6
6
  def column(name, type, limit: nil, **)
7
- Engine.foreign_key_check(name, type, limit: limit)
7
+ Switchman.foreign_key_check(name, type, limit: limit)
8
8
  super
9
9
  end
10
10
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Switchman
4
+ module ActiveRecord
5
+ module TestFixtures
6
+ FORBIDDEN_DB_ENVS = %i[development production].freeze
7
+ def setup_fixtures(config = ::ActiveRecord::Base)
8
+ super
9
+
10
+ return unless run_in_transaction?
11
+
12
+ # Replace the one that activerecord natively uses with a switchman-optimized one
13
+ ::ActiveSupport::Notifications.unsubscribe(@connection_subscriber)
14
+ # Code adapted from the code in rails proper
15
+ @connection_subscriber = ::ActiveSupport::Notifications.subscribe('!connection.active_record') do |_, _, _, _, payload|
16
+ spec_name = payload[:spec_name] if payload.key?(:spec_name)
17
+ shard = payload[:shard] if payload.key?(:shard)
18
+ setup_shared_connection_pool
19
+
20
+ if spec_name && !FORBIDDEN_DB_ENVS.include?(shard)
21
+ begin
22
+ connection = ::ActiveRecord::Base.connection_handler.retrieve_connection(spec_name, shard: shard)
23
+ rescue ::ActiveRecord::ConnectionNotEstablished, ::ActiveRecord::NoDatabaseError
24
+ connection = nil
25
+ end
26
+
27
+ if connection && !@fixture_connections.include?(connection)
28
+ connection.begin_transaction joinable: false, _lazy: false
29
+ connection.pool.lock_thread = true if lock_threads
30
+ @fixture_connections << connection
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ def enlist_fixture_connections
37
+ setup_shared_connection_pool
38
+
39
+ ::ActiveRecord::Base.connection_handler.connection_pool_list.reject { |cp| FORBIDDEN_DB_ENVS.include?(cp.db_config.env_name.to_sym) }.map(&:connection)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -4,6 +4,22 @@ module Switchman
4
4
  module ActiveSupport
5
5
  module Cache
6
6
  module ClassMethods
7
+ def lookup_stores(cache_store_config)
8
+ result = {}
9
+ cache_store_config.each do |key, value|
10
+ next if value.is_a?(String)
11
+
12
+ result[key] = ::ActiveSupport::Cache.lookup_store(value)
13
+ end
14
+
15
+ cache_store_config.each do |key, value| # rubocop:disable Style/CombinableLoops
16
+ next unless value.is_a?(String)
17
+
18
+ result[key] = result[value]
19
+ end
20
+ result
21
+ end
22
+
7
23
  def lookup_store(*store_options)
8
24
  store = super
9
25
  # can't use defined?, because it's a _ruby_ autoloaded constant,
@@ -11,22 +11,44 @@ module Switchman
11
11
  module Visitors
12
12
  module ToSql
13
13
  # rubocop:disable Naming/MethodName
14
+ # rubocop:disable Naming/MethodParameterName
14
15
 
15
- def visit_Arel_Nodes_TableAlias(*args)
16
- o, collector = args
16
+ def visit_Arel_Nodes_TableAlias(o, collector)
17
17
  collector = visit o.relation, collector
18
18
  collector << ' '
19
19
  collector << quote_local_table_name(o.name)
20
20
  end
21
21
 
22
- def visit_Arel_Attributes_Attribute(*args)
23
- o = args.first
22
+ def visit_Arel_Attributes_Attribute(o, collector)
24
23
  join_name = o.relation.table_alias || o.relation.name
25
- result = "#{quote_local_table_name join_name}.#{quote_column_name o.name}"
26
- args.last << result
24
+ collector << quote_local_table_name(join_name) << '.' << quote_column_name(o.name)
25
+ end
26
+
27
+ def visit_Arel_Nodes_HomogeneousIn(o, collector)
28
+ collector.preparable = false
29
+
30
+ collector << quote_local_table_name(o.table_name) << '.' << quote_column_name(o.column_name)
31
+
32
+ collector << if o.type == :in
33
+ ' IN ('
34
+ else
35
+ ' NOT IN ('
36
+ end
37
+
38
+ values = o.casted_values
39
+
40
+ if values.empty?
41
+ collector << @connection.quote(nil)
42
+ else
43
+ collector.add_binds(values, o.proc_for_binds, &bind_block)
44
+ end
45
+
46
+ collector << ')'
47
+ collector
27
48
  end
28
49
 
29
50
  # rubocop:enable Naming/MethodName
51
+ # rubocop:enable Naming/MethodParameterName
30
52
 
31
53
  def quote_local_table_name(name)
32
54
  return name if ::Arel::Nodes::SqlLiteral === name
@@ -8,15 +8,12 @@ module Switchman
8
8
 
9
9
  class << self
10
10
  attr_accessor :creating_new_shard
11
+ attr_reader :all_roles
11
12
 
12
13
  def all
13
14
  database_servers.values
14
15
  end
15
16
 
16
- def all_roles
17
- @all_roles ||= all.map(&:roles).flatten.uniq
18
- end
19
-
20
17
  def find(id_or_all)
21
18
  return all if id_or_all == :all
22
19
  return id_or_all.map { |id| database_servers[id || ::Rails.env] }.compact.uniq if id_or_all.is_a?(Array)
@@ -38,7 +35,7 @@ module Switchman
38
35
  database_servers[server.id] = server
39
36
  ::ActiveRecord::Base.configurations.configurations <<
40
37
  ::ActiveRecord::DatabaseConfigurations::HashConfig.new(::Rails.env, "#{server.id}/primary", settings)
41
- Shard.send(:initialize_sharding)
38
+ Shard.send(:configure_connects_to)
42
39
  server
43
40
  end
44
41
 
@@ -49,31 +46,42 @@ module Switchman
49
46
  servers[rand(servers.length)]
50
47
  end
51
48
 
49
+ def guard_servers
50
+ all.each { |db| db.guard! if db.config[:prefer_secondary] }
51
+ end
52
+
52
53
  private
53
54
 
54
55
  def reference_role(role)
55
56
  return if all_roles.include?(role)
56
57
 
57
58
  @all_roles << role
58
- Shard.send(:initialize_sharding)
59
+ Shard.send(:configure_connects_to)
59
60
  end
60
61
 
61
62
  def database_servers
62
- unless @database_servers
63
+ if !@database_servers || @database_servers.empty?
63
64
  @database_servers = {}.with_indifferent_access
65
+ roles = []
64
66
  ::ActiveRecord::Base.configurations.configurations.each do |config|
65
67
  if config.name.include?('/')
66
68
  name, role = config.name.split('/')
67
69
  else
68
70
  name, role = config.env_name, config.name
69
71
  end
72
+ role = role.to_sym
70
73
 
71
- if role == 'primary'
74
+ roles << role
75
+ if role == :primary
72
76
  @database_servers[name] = DatabaseServer.new(config.env_name, config.configuration_hash)
73
77
  else
74
78
  @database_servers[name].roles << role
75
79
  end
76
80
  end
81
+ # Do this after so that all database servers for all roles are established and we won't prematurely
82
+ # configure a connection for the wrong role
83
+ @all_roles = roles.uniq
84
+ Shard.send(:configure_connects_to)
77
85
  end
78
86
  @database_servers
79
87
  end
@@ -89,13 +97,13 @@ module Switchman
89
97
  end
90
98
 
91
99
  def connects_to_hash
92
- self.class.all_roles.map do |role|
100
+ self.class.all_roles.to_h do |role|
93
101
  config_role = role
94
102
  config_role = :primary unless roles.include?(role)
95
103
  config_name = :"#{id}/#{config_role}"
96
104
  config_name = :primary if id == ::Rails.env && config_role == :primary
97
105
  [role.to_sym, config_name]
98
- end.to_h
106
+ end
99
107
  end
100
108
 
101
109
  def destroy
@@ -129,27 +137,27 @@ module Switchman
129
137
  end
130
138
  end
131
139
 
132
- def guard_rail_environment
133
- @guard_rail_environment || ::GuardRail.environment
134
- end
135
-
136
140
  # locks this db to a specific environment, except for
137
141
  # when doing writes (then it falls back to the current
138
142
  # value of GuardRail.environment)
139
143
  def guard!(environment = :secondary)
140
- @guard_rail_environment = environment
144
+ DatabaseServer.send(:reference_role, environment)
145
+ ::ActiveRecord::Base.connected_to_stack << { shard_roles: { id.to_sym => environment }, klasses: [::ActiveRecord::Base] }
141
146
  end
142
147
 
143
148
  def unguard!
144
- @guard_rail_environment = nil
149
+ ::ActiveRecord::Base.connected_to_stack << { shard_roles: { id.to_sym => :_switchman_inherit }, klasses: [::ActiveRecord::Base] }
145
150
  end
146
151
 
147
152
  def unguard
148
- old_env = @guard_rail_environment
149
- unguard!
150
- yield
151
- ensure
152
- guard!(old_env)
153
+ return yield unless ::ActiveRecord::Base.role_overriden?(id.to_sym)
154
+
155
+ begin
156
+ unguard!
157
+ yield
158
+ ensure
159
+ ::ActiveRecord::Base.connected_to_stack.pop
160
+ end
153
161
  end
154
162
 
155
163
  def shards
@@ -186,58 +194,56 @@ module Switchman
186
194
 
187
195
  name ||= "#{config[:database]}_shard_#{id}"
188
196
 
197
+ schema_already_existed = false
198
+ shard = nil
189
199
  Shard.connection.transaction do
190
- shard = Shard.create!(id: id,
191
- name: name,
192
- database_server_id: self.id)
193
- schema_already_existed = false
194
-
195
- begin
196
- self.class.creating_new_shard = true
197
- DatabaseServer.send(:reference_role, :deploy)
198
- ::ActiveRecord::Base.connected_to(shard: self.id.to_sym, role: :deploy) do
199
- if create_statement
200
- if ::ActiveRecord::Base.connection.select_value("SELECT 1 FROM pg_namespace WHERE nspname=#{::ActiveRecord::Base.connection.quote(name)}")
201
- schema_already_existed = true
202
- raise 'This schema already exists; cannot overwrite'
203
- end
204
- Array(create_statement.call).each do |stmt|
205
- ::ActiveRecord::Base.connection.execute(stmt)
206
- end
200
+ self.class.creating_new_shard = true
201
+ DatabaseServer.send(:reference_role, :deploy)
202
+ ::ActiveRecord::Base.connected_to(shard: self.id.to_sym, role: :deploy) do
203
+ shard = Shard.create!(id: id,
204
+ name: name,
205
+ database_server_id: self.id)
206
+ if create_statement
207
+ if ::ActiveRecord::Base.connection.select_value("SELECT 1 FROM pg_namespace WHERE nspname=#{::ActiveRecord::Base.connection.quote(name)}")
208
+ schema_already_existed = true
209
+ raise 'This schema already exists; cannot overwrite'
207
210
  end
208
- if config[:adapter] == 'postgresql'
209
- old_proc = ::ActiveRecord::Base.connection.raw_connection.set_notice_processor do
210
- end
211
+ Array(create_statement.call).each do |stmt|
212
+ ::ActiveRecord::Base.connection.execute(stmt)
213
+ end
214
+ end
215
+ if config[:adapter] == 'postgresql'
216
+ old_proc = ::ActiveRecord::Base.connection.raw_connection.set_notice_processor do
211
217
  end
212
- old_verbose = ::ActiveRecord::Migration.verbose
213
- ::ActiveRecord::Migration.verbose = false
214
-
215
- unless schema == false
216
- shard.activate(*Shard.sharded_models) do
217
- reset_column_information
218
-
219
- ::ActiveRecord::Base.connection.transaction(requires_new: true) do
220
- ::ActiveRecord::Base.connection.migration_context.migrate
221
- end
222
- reset_column_information
223
- ::ActiveRecord::Base.descendants.reject do |m|
224
- m <= UnshardedRecord || !m.table_exists?
225
- end.each(&:define_attribute_methods)
218
+ end
219
+ old_verbose = ::ActiveRecord::Migration.verbose
220
+ ::ActiveRecord::Migration.verbose = false
221
+
222
+ unless schema == false
223
+ shard.activate do
224
+ reset_column_information
225
+
226
+ ::ActiveRecord::Base.connection.transaction(requires_new: true) do
227
+ ::ActiveRecord::Base.connection.migration_context.migrate
226
228
  end
229
+ reset_column_information
230
+ ::ActiveRecord::Base.descendants.reject do |m|
231
+ m <= UnshardedRecord || !m.table_exists?
232
+ end.each(&:define_attribute_methods)
227
233
  end
228
- ensure
229
- ::ActiveRecord::Migration.verbose = old_verbose
230
- ::ActiveRecord::Base.connection.raw_connection.set_notice_processor(&old_proc) if old_proc
231
234
  end
232
- shard
233
- rescue
234
- shard.destroy
235
- shard.drop_database rescue nil unless schema_already_existed
236
- reset_column_information unless schema == false rescue nil
237
- raise
238
235
  ensure
239
- self.class.creating_new_shard = false
236
+ ::ActiveRecord::Migration.verbose = old_verbose
237
+ ::ActiveRecord::Base.connection.raw_connection.set_notice_processor(&old_proc) if old_proc
240
238
  end
239
+ shard
240
+ rescue
241
+ shard&.destroy
242
+ shard&.drop_database rescue nil unless schema_already_existed
243
+ reset_column_information unless schema == false rescue nil
244
+ raise
245
+ ensure
246
+ self.class.creating_new_shard = false
241
247
  end
242
248
  end
243
249
 
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'switchman/database_server'
4
-
5
3
  module Switchman
6
4
  class DefaultShard
7
5
  def id