switchman 4.0.0 → 4.2.5

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/lib/switchman/active_record/abstract_adapter.rb +10 -8
  3. data/lib/switchman/active_record/associations.rb +44 -60
  4. data/lib/switchman/active_record/attribute_methods.rb +34 -27
  5. data/lib/switchman/active_record/base.rb +12 -61
  6. data/lib/switchman/active_record/calculations.rb +38 -49
  7. data/lib/switchman/active_record/connection_pool.rb +37 -23
  8. data/lib/switchman/active_record/database_configurations.rb +7 -19
  9. data/lib/switchman/active_record/finder_methods.rb +21 -45
  10. data/lib/switchman/active_record/log_subscriber.rb +1 -10
  11. data/lib/switchman/active_record/migration.rb +14 -33
  12. data/lib/switchman/active_record/persistence.rb +1 -1
  13. data/lib/switchman/active_record/postgresql_adapter.rb +26 -12
  14. data/lib/switchman/active_record/query_cache.rb +22 -42
  15. data/lib/switchman/active_record/query_methods.rb +63 -22
  16. data/lib/switchman/active_record/reflection.rb +9 -2
  17. data/lib/switchman/active_record/relation.rb +64 -5
  18. data/lib/switchman/active_record/spawn_methods.rb +1 -5
  19. data/lib/switchman/active_record/statement_cache.rb +2 -2
  20. data/lib/switchman/active_record/table_definition.rb +1 -1
  21. data/lib/switchman/active_record/test_fixtures.rb +71 -35
  22. data/lib/switchman/arel.rb +0 -25
  23. data/lib/switchman/database_server.rb +5 -9
  24. data/lib/switchman/engine.rb +10 -5
  25. data/lib/switchman/shard.rb +10 -12
  26. data/lib/switchman/sharded_instrumenter.rb +8 -2
  27. data/lib/switchman/test_helper.rb +1 -1
  28. data/lib/switchman/version.rb +1 -1
  29. data/lib/tasks/switchman.rake +1 -1
  30. metadata +25 -159
@@ -28,15 +28,15 @@ module Switchman
28
28
  relation
29
29
  end
30
30
 
31
- def new(*, &block)
31
+ def new(*, &)
32
32
  primary_shard.activate(klass.connection_class_for_self) { super }
33
33
  end
34
34
 
35
- def create(*, &block)
35
+ def create(*, &)
36
36
  primary_shard.activate(klass.connection_class_for_self) { super }
37
37
  end
38
38
 
39
- def create!(*, &block)
39
+ def create!(*, &)
40
40
  primary_shard.activate(klass.connection_class_for_self) { super }
41
41
  end
42
42
 
@@ -73,6 +73,59 @@ module Switchman
73
73
  RUBY
74
74
  end
75
75
 
76
+ # https://github.com/rails/rails/commit/ed2c15b52450ff927a05629f031376f25b670335
77
+ # once the minimum version is Rails 7.2, we can drop this separate module
78
+ module InsertUpsertAll
79
+ %w[insert_all upsert_all].each do |method|
80
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
81
+ def #{method}(attributes, returning: nil, **)
82
+ scope = self != ::ActiveRecord::Base && current_scope
83
+ if (target_shard = scope&.primary_shard) == (current_shard = Shard.current(connection_class_for_self))
84
+ scope = nil
85
+ end
86
+ if scope
87
+ dupped = false
88
+ attributes.each_with_index do |hash, i|
89
+ if dupped || hash.any? { |k, v| sharded_column?(k) }
90
+ unless dupped
91
+ attributes = attributes.dup
92
+ dupped = true
93
+ end
94
+ attributes[i] = hash.to_h do |k, v|
95
+ if sharded_column?(k)
96
+ [k, Shard.relative_id_for(v, current_shard, target_shard)]
97
+ else
98
+ [k, v]
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+
105
+ if scope
106
+ scope.activate do
107
+ db = Shard.current(connection_class_for_self).database_server
108
+ result = db.unguard { super }
109
+ if result&.columns&.any? { |c| sharded_column?(c) }
110
+ transposed_rows = result.rows.map do |row|
111
+ row.map.with_index do |value, i|
112
+ sharded_column?(result.columns[i]) ? Shard.relative_id_for(value, target_shard, current_shard) : value
113
+ end
114
+ end
115
+ result = ::ActiveRecord::Result.new(result.columns, transposed_rows, result.column_types)
116
+ end
117
+
118
+ result
119
+ end
120
+ else
121
+ db = Shard.current(connection_class_for_self).database_server
122
+ db.unguard { super }
123
+ end
124
+ end
125
+ RUBY
126
+ end
127
+ end
128
+
76
129
  def find_ids_in_ranges(options = {})
77
130
  is_integer = columns_hash[primary_key.to_s].type == :integer
78
131
  loose_mode = options[:loose] && is_integer
@@ -110,7 +163,7 @@ module Switchman
110
163
  end
111
164
  end
112
165
 
113
- def activate(unordered: false, &block)
166
+ def activate(count: false, unordered: false, &block)
114
167
  shards = all_shards
115
168
  if Array === shards && shards.length == 1
116
169
  if !loaded? && shard_value != shards.first
@@ -136,7 +189,13 @@ module Switchman
136
189
 
137
190
  shard_results = relation.activate(&block)
138
191
 
139
- if shard_results.present? && !unordered
192
+ if shard_results.present? && count
193
+ unless shard_results.is_a?(Integer)
194
+ raise "expected integer result for count, got #{shard_results.class.name}"
195
+ end
196
+
197
+ result_count += shard_results
198
+ elsif shard_results.present? && !unordered
140
199
  can_order ||= can_order_cross_shard_results? unless order_values.empty?
141
200
  raise OrderOnMultiShardQuery if !can_order && !order_values.empty? && result_count.positive?
142
201
 
@@ -62,16 +62,12 @@ module Switchman
62
62
  if primary_shard != final_primary_shard && rhs.primary_shard != final_primary_shard
63
63
  shard!(final_primary_shard)
64
64
  rhs = rhs.shard(final_primary_shard)
65
- super(rhs)
66
65
  elsif primary_shard != final_primary_shard
67
66
  shard!(final_primary_shard)
68
- super(rhs)
69
67
  elsif rhs.primary_shard != final_primary_shard
70
68
  rhs = rhs.shard(final_primary_shard)
71
- super(rhs)
72
- else
73
- super
74
69
  end
70
+ super
75
71
 
76
72
  self.shard_value = final_shard_value
77
73
  self.shard_source_value = final_shard_source_value
@@ -29,14 +29,14 @@ module Switchman
29
29
  params, connection = args
30
30
  klass = @klass
31
31
  target_shard = nil
32
- if (primary_index = bind_map.primary_value_index)
32
+ if (primary_index = @bind_map.primary_value_index)
33
33
  primary_value = params[primary_index]
34
34
  target_shard = Shard.local_id_for(primary_value)[1]
35
35
  end
36
36
  current_shard = Shard.current(klass.connection_class_for_self)
37
37
  target_shard ||= current_shard
38
38
 
39
- bind_values = bind_map.bind(params, current_shard, target_shard)
39
+ bind_values = @bind_map.bind(params, current_shard, target_shard)
40
40
 
41
41
  target_shard.activate(klass.connection_class_for_self) do
42
42
  sql = qualified_query_builder(target_shard, klass).sql_for(bind_values, connection)
@@ -4,7 +4,7 @@ module Switchman
4
4
  module ActiveRecord
5
5
  module TableDefinition
6
6
  def column(name, type, limit: nil, **)
7
- Switchman.foreign_key_check(name, type, limit: limit)
7
+ Switchman.foreign_key_check(name, type, limit:)
8
8
  super
9
9
  end
10
10
  end
@@ -4,49 +4,85 @@ module Switchman
4
4
  module ActiveRecord
5
5
  module TestFixtures
6
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 =
16
- ::ActiveSupport::Notifications.subscribe("!connection.active_record") do |_, _, _, _, payload|
17
- spec_name = if ::Rails.version < "7.1"
18
- payload[:spec_name] if payload.key?(:spec_name)
19
- elsif payload.key?(:connection_name)
20
- payload[:connection_name]
21
- end
22
- shard = payload[:shard] if payload.key?(:shard)
23
7
 
24
- if spec_name && !FORBIDDEN_DB_ENVS.include?(shard)
25
- begin
26
- connection = ::ActiveRecord::Base.connection_handler.retrieve_connection(spec_name, shard: shard)
27
- connection.connect! if ::Rails.version >= "7.1" # eagerly validate the connection
28
- rescue ::ActiveRecord::ConnectionNotEstablished, ::ActiveRecord::NoDatabaseError
29
- connection = nil
30
- end
8
+ if ::Rails.version < "7.2"
9
+ def setup_fixtures(config = ::ActiveRecord::Base)
10
+ super
11
+ return unless run_in_transaction?
31
12
 
32
- if connection
33
- setup_shared_connection_pool
34
- unless @fixture_connections.include?(connection)
35
- connection.begin_transaction joinable: false, _lazy: false
36
- connection.pool.lock_thread = true if lock_threads
37
- @fixture_connections << connection
13
+ # Replace the one that activerecord natively uses with a switchman-optimized one
14
+ ::ActiveSupport::Notifications.unsubscribe(@connection_subscriber)
15
+ # Code adapted from the code in rails proper
16
+ @connection_subscriber =
17
+ ::ActiveSupport::Notifications.subscribe("!connection.active_record") do |_, _, _, _, payload|
18
+ spec_name = (payload[:connection_name] if payload.key?(:connection_name))
19
+ shard = payload[:shard] if payload.key?(:shard)
20
+
21
+ if spec_name && !FORBIDDEN_DB_ENVS.include?(shard)
22
+ begin
23
+ connection = ::ActiveRecord::Base.connection_handler.retrieve_connection(spec_name, shard:)
24
+ connection.connect! # eagerly validate the connection
25
+ rescue ::ActiveRecord::ConnectionNotEstablished
26
+ connection = nil
27
+ end
28
+
29
+ if connection
30
+ setup_shared_connection_pool
31
+ unless @fixture_connections.include?(connection)
32
+ connection.begin_transaction joinable: false, _lazy: false
33
+ connection.pool.lock_thread = true if lock_threads
34
+ @fixture_connections << connection
35
+ end
38
36
  end
39
37
  end
40
38
  end
39
+ end
40
+
41
+ def enlist_fixture_connections
42
+ setup_shared_connection_pool
43
+
44
+ ::ActiveRecord::Base.connection_handler.connection_pool_list(:primary).reject do |cp|
45
+ FORBIDDEN_DB_ENVS.include?(cp.db_config.env_name.to_sym)
46
+ end.map(&:connection)
47
+ end
48
+ else
49
+ def setup_transactional_fixtures
50
+ setup_shared_connection_pool
51
+
52
+ # Begin transactions for connections already established
53
+ # INST: :writing -> :primary
54
+ @fixture_connection_pools = ::ActiveRecord::Base.connection_handler.connection_pool_list(:primary)
55
+ # INST: filter by FORBIDDEN_DB_ENVS
56
+ @fixture_connection_pools = @fixture_connection_pools.reject do |cp|
57
+ FORBIDDEN_DB_ENVS.include?(cp.db_config.env_name.to_sym)
41
58
  end
42
- end
43
59
 
44
- def enlist_fixture_connections
45
- setup_shared_connection_pool
60
+ @fixture_connection_pools.each do |pool|
61
+ pool.pin_connection!(lock_threads)
62
+ pool.lease_connection
63
+ end
64
+
65
+ # When connections are established in the future, begin a transaction too
66
+ @connection_subscriber = ::ActiveSupport::Notifications
67
+ .subscribe("!connection.active_record") do |_, _, _, _, payload|
68
+ connection_name = payload[:connection_name] if payload.key?(:connection_name)
69
+ shard = payload[:shard] if payload.key?(:shard)
70
+
71
+ # INST: filter by FORBIDDEN_DB_ENVS
72
+ if connection_name && !FORBIDDEN_DB_ENVS.include?(shard)
73
+ pool = ::ActiveRecord::Base.connection_handler.retrieve_connection_pool(connection_name, shard:)
74
+ if pool
75
+ setup_shared_connection_pool
46
76
 
47
- ::ActiveRecord::Base.connection_handler.connection_pool_list(:primary).reject do |cp|
48
- FORBIDDEN_DB_ENVS.include?(cp.db_config.env_name.to_sym)
49
- end.map(&:connection)
77
+ unless @fixture_connection_pools.include?(pool)
78
+ pool.pin_connection!(lock_threads)
79
+ pool.lease_connection
80
+ @fixture_connection_pools << pool
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
50
86
  end
51
87
  end
52
88
  end
@@ -38,31 +38,6 @@ module Switchman
38
38
  collector << quote_local_table_name(join_name) << "." << quote_column_name(o.name)
39
39
  end
40
40
 
41
- if ::Rails.version < "7.1"
42
- def visit_Arel_Nodes_HomogeneousIn(o, collector)
43
- collector.preparable = false
44
-
45
- collector << quote_local_table_name(o.table_name) << "." << quote_column_name(o.column_name)
46
-
47
- collector << if o.type == :in
48
- " IN ("
49
- else
50
- " NOT IN ("
51
- end
52
-
53
- values = o.casted_values
54
-
55
- if values.empty?
56
- collector << @connection.quote(nil)
57
- else
58
- collector.add_binds(values, o.proc_for_binds, &bind_block)
59
- end
60
-
61
- collector << ")"
62
- collector
63
- end
64
- end
65
-
66
41
  # rubocop:enable Naming/MethodName
67
42
  # rubocop:enable Naming/MethodParameterName
68
43
 
@@ -121,7 +121,7 @@ module Switchman
121
121
  Shard.sharded_models.each do |klass|
122
122
  self.class.all_roles.each do |role|
123
123
  klass.connection_handler.remove_connection_pool(klass.connection_specification_name,
124
- role: role,
124
+ role:,
125
125
  shard: id.to_sym)
126
126
  end
127
127
  end
@@ -218,7 +218,7 @@ module Switchman
218
218
  if config_create_statement
219
219
  create_commands = Array(config_create_statement).dup
220
220
  create_statement = lambda {
221
- create_commands.map { |statement| format(statement, name: name, password: password) }
221
+ create_commands.map { |statement| format(statement, name:, password:) }
222
222
  }
223
223
  end
224
224
 
@@ -236,8 +236,8 @@ module Switchman
236
236
  self.class.creating_new_shard = true
237
237
  DatabaseServer.send(:reference_role, :deploy)
238
238
  ::ActiveRecord::Base.connected_to(shard: self.id.to_sym, role: :deploy) do
239
- shard = Shard.create!(id: id,
240
- name: name,
239
+ shard = Shard.create!(id:,
240
+ name:,
241
241
  database_server_id: self.id)
242
242
  if create_statement
243
243
  if ::ActiveRecord::Base.connection.select_value(
@@ -259,11 +259,7 @@ module Switchman
259
259
  unless schema == false
260
260
  shard.activate do
261
261
  ::ActiveRecord::Base.connection.transaction(requires_new: true) do
262
- if ::Rails.version < "7.1"
263
- ::ActiveRecord::Base.connection.migration_context.migrate
264
- else
265
- ::ActiveRecord::MigrationContext.new(::ActiveRecord::Migrator.migrations_paths).migrate
266
- end
262
+ ::ActiveRecord::MigrationContext.new(::ActiveRecord::Migrator.migrations_paths).migrate
267
263
  end
268
264
 
269
265
  ::ActiveRecord::Base.descendants.reject do |m|
@@ -4,8 +4,6 @@ module Switchman
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace Switchman
6
6
 
7
- # enable Rails 6.1 style connection handling
8
- config.active_record.legacy_connection_handling = false if ::Rails.version < "7.1"
9
7
  config.active_record.writing_role = :primary
10
8
 
11
9
  ::GuardRail.singleton_class.prepend(GuardRail::ClassMethods)
@@ -47,12 +45,18 @@ module Switchman
47
45
  ActiveRecord::Associations::Preloader::Association::LoaderRecords
48
46
  )
49
47
  ::ActiveRecord::ConnectionAdapters::AbstractAdapter.prepend(ActiveRecord::AbstractAdapter)
50
- unless ::Rails.version < "7.1"
51
- ::ActiveRecord::ConnectionAdapters::ConnectionHandler.prepend(ActiveRecord::ConnectionHandler)
52
- end
48
+ ::ActiveRecord::ConnectionAdapters::ConnectionHandler.prepend(ActiveRecord::ConnectionHandler)
53
49
  ::ActiveRecord::ConnectionAdapters::ConnectionPool.prepend(ActiveRecord::ConnectionPool)
54
50
  ::ActiveRecord::ConnectionAdapters::AbstractAdapter.prepend(ActiveRecord::QueryCache)
55
51
  ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(ActiveRecord::PostgreSQLAdapter)
52
+ # https://github.com/rails/rails/commit/0016280f4fde55d96738887093dc333aae0d107b
53
+ if ::Rails.version < "7.2"
54
+ ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend(ActiveRecord::PostgreSQLAdapter::ClassMethods)
55
+ else
56
+ ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.singleton_class.prepend(
57
+ ActiveRecord::PostgreSQLAdapter::ClassMethods
58
+ )
59
+ end
56
60
 
57
61
  ::ActiveRecord::DatabaseConfigurations.prepend(ActiveRecord::DatabaseConfigurations)
58
62
  ::ActiveRecord::DatabaseConfigurations::DatabaseConfig.prepend(
@@ -79,6 +83,7 @@ module Switchman
79
83
  ::ActiveRecord::Relation.include(ActiveRecord::QueryMethods)
80
84
  ::ActiveRecord::Relation.prepend(GuardRail::Relation)
81
85
  ::ActiveRecord::Relation.prepend(ActiveRecord::Relation)
86
+ ::ActiveRecord::Relation.prepend(ActiveRecord::Relation::InsertUpsertAll) if ::Rails.version >= "7.2"
82
87
  ::ActiveRecord::Relation.include(ActiveRecord::SpawnMethods)
83
88
  ::ActiveRecord::Relation.include(CallSuper)
84
89
 
@@ -5,8 +5,10 @@ module Switchman
5
5
  # ten trillion possible ids per shard. yup.
6
6
  IDS_PER_SHARD = 10_000_000_000_000
7
7
 
8
+ # rubocop:disable Style/SymbolProc -- transforming to a lambda produces "no receiver given"
8
9
  # only allow one default
9
10
  validates_uniqueness_of :default, if: ->(s) { s.default? }
11
+ # rubocop:enable Style/SymbolProc
10
12
 
11
13
  after_save :clear_cache
12
14
  after_destroy :clear_cache
@@ -51,7 +53,7 @@ module Switchman
51
53
  return [default] unless default.is_a?(Switchman::Shard)
52
54
  return all if !Switchman.region || DatabaseServer.none?(&:region)
53
55
 
54
- in_region(Switchman.region, include_regionless: include_regionless)
56
+ in_region(Switchman.region, include_regionless:)
55
57
  end)
56
58
 
57
59
  class << self
@@ -141,7 +143,7 @@ module Switchman
141
143
 
142
144
  unless cached_shards.key?(id)
143
145
  cached_shards[id] = Shard.default.activate do
144
- find_cached(["shard", id]) { find_by(id: id) }
146
+ find_cached(["shard", id]) { find_by(id:) }
145
147
  end
146
148
  end
147
149
  cached_shards[id]
@@ -202,7 +204,7 @@ module Switchman
202
204
  database_servers = scope.reorder("database_server_id").select(:database_server_id).distinct
203
205
  .filter_map(&:database_server).uniq
204
206
  # nothing to do
205
- return if database_servers.count.zero?
207
+ return if database_servers.none?
206
208
 
207
209
  scopes = database_servers.to_h do |server|
208
210
  [server, scope.merge(server.shards)]
@@ -214,11 +216,7 @@ module Switchman
214
216
  # clear connections prior to forking (no more queries will be executed in the parent,
215
217
  # and we want them gone so that we don't accidentally use them post-fork doing something
216
218
  # silly like dealloc'ing prepared statements)
217
- if ::Rails.version < "7.1"
218
- ::ActiveRecord::Base.clear_all_connections!(nil)
219
- else
220
- ::ActiveRecord::Base.connection_handler.clear_all_connections!(:all)
221
- end
219
+ ::ActiveRecord::Base.connection_handler.clear_all_connections!(:all)
222
220
 
223
221
  parent_process_name = sanitized_process_title
224
222
  ret = ::Parallel.map(scopes, in_processes: (scopes.length > 1) ? parallel : 0) do |server, subscope|
@@ -233,7 +231,7 @@ module Switchman
233
231
  Switchman.config[:on_fork_proc]&.call
234
232
  with_each_shard(subscope,
235
233
  classes,
236
- exception: exception,
234
+ exception:,
237
235
  output: output || :decorated) do
238
236
  last_description = Shard.current.description
239
237
  Parallel::ResultWrapper.new(yield)
@@ -459,7 +457,7 @@ module Switchman
459
457
  connects_to_hash.each do |(db_name, role_hash)|
460
458
  role_hash.each_key do |role|
461
459
  role_hash.delete(role) if klass.connection_handler.retrieve_connection_pool(
462
- klass.connection_specification_name, role: role, shard: db_name
460
+ klass.connection_specification_name, role:, shard: db_name
463
461
  )
464
462
  end
465
463
  end
@@ -590,9 +588,9 @@ module Switchman
590
588
  id
591
589
  end
592
590
 
593
- def activate(*classes, &block)
591
+ def activate(*classes, &)
594
592
  shards = hashify_classes(classes)
595
- Shard.activate(shards, &block)
593
+ Shard.activate(shards, &)
596
594
  end
597
595
 
598
596
  # for use from console ONLY
@@ -13,13 +13,19 @@ module Switchman
13
13
  # when we might be doing a query while defining attribute methods,
14
14
  # so just avoid logging then
15
15
  if shard.is_a?(Shard) && Shard.instance_variable_get(:@attribute_methods_generated)
16
+ env = if ::Rails.version < "8.0"
17
+ @shard_host.pool.connection_class&.current_role
18
+ else
19
+ @shard_host.pool.connection_descriptor&.name&.constantize&.current_role
20
+ end
21
+
16
22
  payload[:shard] = {
17
23
  database_server_id: shard.database_server.id,
18
24
  id: shard.id,
19
- env: @shard_host.pool.connection_class&.current_role
25
+ env:
20
26
  }
21
27
  end
22
- super(name, payload)
28
+ super
23
29
  end
24
30
  end
25
31
  end
@@ -63,7 +63,7 @@ module Switchman
63
63
 
64
64
  def find_existing_test_shard(server, name)
65
65
  if server == Shard.default.database_server
66
- server.shards.where(name: name).first
66
+ server.shards.where(name:).first
67
67
  else
68
68
  shard = Shard.where("database_server_id IS NOT NULL AND name=?", name).first
69
69
  # if somehow databases got created in a different order, change the shard to match
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Switchman
4
- VERSION = "4.0.0"
4
+ VERSION = "4.2.5"
5
5
  end
@@ -94,7 +94,7 @@ module Switchman
94
94
  else
95
95
  nil
96
96
  end
97
- Shard.with_each_shard(scope, classes, output: output, **options) do
97
+ Shard.with_each_shard(scope, classes, output:, **options) do
98
98
  shard = Shard.current
99
99
 
100
100
  if log_format == "json"