switchman 3.0.9 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +1 -1
  3. data/lib/switchman/action_controller/caching.rb +2 -2
  4. data/lib/switchman/active_record/abstract_adapter.rb +2 -4
  5. data/lib/switchman/active_record/associations.rb +223 -0
  6. data/lib/switchman/active_record/attribute_methods.rb +144 -84
  7. data/lib/switchman/active_record/base.rb +71 -31
  8. data/lib/switchman/active_record/calculations.rb +11 -4
  9. data/lib/switchman/active_record/connection_pool.rb +2 -4
  10. data/lib/switchman/active_record/database_configurations.rb +18 -2
  11. data/lib/switchman/active_record/finder_methods.rb +2 -2
  12. data/lib/switchman/active_record/model_schema.rb +1 -1
  13. data/lib/switchman/active_record/persistence.rb +3 -5
  14. data/lib/switchman/active_record/postgresql_adapter.rb +1 -1
  15. data/lib/switchman/active_record/query_methods.rb +20 -11
  16. data/lib/switchman/active_record/reflection.rb +1 -1
  17. data/lib/switchman/active_record/relation.rb +15 -18
  18. data/lib/switchman/active_record/statement_cache.rb +2 -2
  19. data/lib/switchman/active_record/table_definition.rb +1 -1
  20. data/lib/switchman/active_support/cache.rb +16 -0
  21. data/lib/switchman/database_server.rb +26 -18
  22. data/lib/switchman/default_shard.rb +0 -2
  23. data/lib/switchman/engine.rb +63 -125
  24. data/lib/switchman/errors.rb +4 -2
  25. data/lib/switchman/guard_rail/relation.rb +6 -9
  26. data/lib/switchman/guard_rail.rb +5 -0
  27. data/lib/switchman/parallel.rb +68 -0
  28. data/lib/switchman/r_spec_helper.rb +3 -0
  29. data/lib/switchman/rails.rb +2 -5
  30. data/{app/models → lib}/switchman/shard.rb +28 -133
  31. data/lib/switchman/sharded_instrumenter.rb +1 -1
  32. data/lib/switchman/standard_error.rb +10 -11
  33. data/{app/models → lib}/switchman/unsharded_record.rb +1 -1
  34. data/lib/switchman/version.rb +1 -1
  35. data/lib/switchman.rb +22 -2
  36. data/lib/tasks/switchman.rake +16 -9
  37. metadata +20 -20
  38. data/lib/switchman/active_record/association.rb +0 -206
  39. data/lib/switchman/open4.rb +0 -80
@@ -1,10 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'switchman/database_server'
4
- require 'switchman/default_shard'
5
- require 'switchman/environment'
6
- require 'switchman/errors'
7
-
8
3
  module Switchman
9
4
  class Shard < UnshardedRecord
10
5
  # ten trillion possible ids per shard. yup.
@@ -42,12 +37,9 @@ module Switchman
42
37
  # Now find the actual record, if it exists
43
38
  @default = begin
44
39
  find_cached('default_shard') { Shard.where(default: true).take } || default
45
- # If we are *super* early in boot, the connection pool won't exist; we don't want to fill in the default shard yet
46
- # Otherwise, rescue the fake default if the table doesn't exist
47
40
  rescue
48
- sharding_initialized ? default : nil
41
+ default
49
42
  end
50
- return default unless @default
51
43
 
52
44
  # make sure this is not erroneously cached
53
45
  @default.database_server.remove_instance_variable(:@primary_shard) if @default.database_server.instance_variable_defined?(:@primary_shard)
@@ -125,8 +117,7 @@ module Switchman
125
117
  # * +classes+ - an array of classes to activate
126
118
  # parallel: - true/false to execute in parallel, or an integer of how many
127
119
  # sub-processes. Note that parallel invocation currently uses
128
- # forking, so should be used sparingly because you cannot get
129
- # results back
120
+ # forking.
130
121
  # exception: - :ignore, :raise, :defer (wait until the end and raise the first
131
122
  # error), or a proc
132
123
  def with_each_shard(*args, parallel: false, exception: :raise, &block)
@@ -159,137 +150,47 @@ module Switchman
159
150
  return if database_servers.count.zero?
160
151
 
161
152
  scopes = database_servers.to_h do |server|
162
- [server, server.shards.merge(scope)]
153
+ [server, scope.merge(server.shards)]
163
154
  end
164
155
  else
165
156
  scopes = scope.group_by(&:database_server)
166
157
  end
167
158
 
168
- exception_pipes = []
169
- pids = []
170
- out_fds = []
171
- err_fds = []
172
- pid_to_name_map = {}
173
- fd_to_name_map = {}
174
- errors = []
175
-
176
- wait_for_output = lambda do
177
- ready, = IO.select(out_fds + err_fds)
178
- ready.each do |fd|
179
- if fd.eof?
180
- fd.close
181
- out_fds.delete(fd)
182
- err_fds.delete(fd)
183
- next
184
- end
185
- line = fd.readline
186
- puts "#{fd_to_name_map[fd]}: #{line}"
187
- end
188
- end
189
-
190
- # only one process; don't bother forking
191
- return with_each_shard(scopes.first.last, classes, exception: exception, &block) if scopes.length == 1
192
-
193
159
  # clear connections prior to forking (no more queries will be executed in the parent,
194
160
  # and we want them gone so that we don't accidentally use them post-fork doing something
195
161
  # silly like dealloc'ing prepared statements)
196
162
  ::ActiveRecord::Base.clear_all_connections!
197
163
 
198
- scopes.each do |server, subscope|
164
+ parent_process_name = `ps -ocommand= -p#{Process.pid}`.slice(/#{$0}.*/)
165
+ ret = ::Parallel.map(scopes, in_processes: scopes.length > 1 ? parallel : 0) do |server, subscope|
199
166
  name = server.id
200
-
201
- exception_pipe = IO.pipe
202
- exception_pipes << exception_pipe
203
- pid, io_in, io_out, io_err = Open4.pfork4(lambda do
204
- Switchman.config[:on_fork_proc]&.call
205
-
206
- # set a pretty name for the process title, up to 128 characters
207
- # (we don't actually know the limit, depending on how the process
208
- # was started)
209
- # first, simplify the binary name by stripping directories,
210
- # then truncate arguments as necessary
211
- bin = File.basename($0) # Process.argv0 doesn't work on Ruby 2.5 (https://bugs.ruby-lang.org/issues/15887)
212
- max_length = 128 - bin.length - name.length - 3
213
- args = ARGV.join(' ')
214
- args = args[0..max_length] if max_length >= 0
215
- new_title = [bin, args, name].join(' ')
167
+ # rubocop:disable Style/GlobalStdStream
168
+ $stdout = Parallel::PrefixingIO.new(name, STDOUT)
169
+ $stderr = Parallel::PrefixingIO.new(name, STDERR)
170
+ # rubocop:enable Style/GlobalStdStream
171
+ begin
172
+ max_length = 128 - name.length - 3
173
+ short_parent_name = parent_process_name[0..max_length] if max_length >= 0
174
+ new_title = [short_parent_name, name].join(' ')
216
175
  Process.setproctitle(new_title)
217
-
218
- with_each_shard(subscope, classes, exception: exception, &block)
219
- exception_pipe.last.close
220
- rescue Exception => e # rubocop:disable Lint/RescueException
221
- begin
222
- dumped = Marshal.dump(e)
223
- dumped = nil if dumped.length > 64 * 1024
224
- rescue
225
- dumped = nil
226
- end
227
-
228
- if dumped.nil?
229
- # couldn't dump the exception; create a copy with just
230
- # the message and the backtrace
231
- e2 = e.class.new(e.message)
232
- backtrace = e.backtrace
233
- # truncate excessively long backtraces
234
- backtrace = backtrace[0...25] + ['...'] + backtrace[-25..] if backtrace.length > 50
235
- e2.set_backtrace(backtrace)
236
- e2.instance_variable_set(:@active_shards, e.instance_variable_get(:@active_shards))
237
- dumped = Marshal.dump(e2)
238
- end
239
- exception_pipe.last.set_encoding(dumped.encoding)
240
- exception_pipe.last.write(dumped)
241
- exception_pipe.last.flush
242
- exception_pipe.last.close
243
- exit! 1
244
- end)
245
- exception_pipe.last.close
246
- pids << pid
247
- io_in.close # don't care about writing to stdin
248
- out_fds << io_out
249
- err_fds << io_err
250
- pid_to_name_map[pid] = name
251
- fd_to_name_map[io_out] = name
252
- fd_to_name_map[io_err] = name
253
-
254
- while pids.count >= parallel
255
- while out_fds.count >= parallel
256
- # wait for output if we've hit the parallel limit
257
- wait_for_output.call
258
- end
259
- # we've gotten all the output from one fd so wait for its child process to exit
260
- found_pid, status = Process.wait2
261
- pids.delete(found_pid)
262
- errors << pid_to_name_map[found_pid] if status.exitstatus != 0
176
+ Switchman.config[:on_fork_proc]&.call
177
+ with_each_shard(subscope, classes, exception: exception, &block).map { |result| Parallel::ResultWrapper.new(result) }
178
+ rescue => e
179
+ logger.error e.full_message
180
+ Parallel::QuietExceptionWrapper.new(name, ::Parallel::ExceptionWrapper.new(e))
263
181
  end
264
- # we've gotten all the output from one fd so wait for its child process to exit
265
- found_pid, status = Process.wait2
266
- pids.delete(found_pid)
267
- errors << pid_to_name_map[found_pid] if status.exitstatus != 0
268
- end
269
-
270
- wait_for_output.call while out_fds.any? || err_fds.any?
271
- pids.each do |pid|
272
- _, status = Process.waitpid2(pid)
273
- errors << pid_to_name_map[pid] if status.exitstatus != 0
274
- end
275
-
276
- # check for an exception; we only re-raise the first one
277
- exception_pipes.each do |exception_pipe|
278
- serialized_exception = exception_pipe.first.read
279
- next if serialized_exception.empty?
280
-
281
- ex = Marshal.load(serialized_exception) # rubocop:disable Security/MarshalLoad
282
- raise ex
283
- ensure
284
- exception_pipe.first.close
285
- end
182
+ end.flatten
286
183
 
184
+ errors = ret.select { |val| val.is_a?(Parallel::QuietExceptionWrapper) }
287
185
  unless errors.empty?
288
- raise ParallelShardExecError,
289
- "The following subprocesses did not exit cleanly: #{errors.sort.join(', ')}"
186
+ raise errors.first.exception if errors.length == 1
187
+
188
+ raise Errors::ParallelShardExecError,
189
+ "The following database server(s) did not finish processing cleanly: #{errors.map(&:name).sort.join(', ')}",
190
+ cause: errors.first.exception
290
191
  end
291
192
 
292
- return
193
+ return ret.map(&:result)
293
194
  end
294
195
 
295
196
  classes ||= []
@@ -467,16 +368,12 @@ module Switchman
467
368
 
468
369
  private
469
370
 
470
- def sharding_initialized
471
- @sharding_initialized ||= false
472
- end
473
-
474
371
  def add_sharded_model(klass)
475
372
  @sharded_models = (sharded_models + [klass]).freeze
476
- initialize_sharding
373
+ configure_connects_to
477
374
  end
478
375
 
479
- def initialize_sharding
376
+ def configure_connects_to
480
377
  full_connects_to_hash = DatabaseServer.all.to_h { |db| [db.id.to_sym, db.connects_to_hash] }
481
378
  sharded_models.each do |klass|
482
379
  connects_to_hash = full_connects_to_hash.deep_dup
@@ -498,8 +395,6 @@ module Switchman
498
395
 
499
396
  klass.connects_to shards: connects_to_hash
500
397
  end
501
-
502
- @sharding_initialized = true
503
398
  end
504
399
 
505
400
  # in-process caching
@@ -16,7 +16,7 @@ module Switchman
16
16
  payload[:shard] = {
17
17
  database_server_id: shard.database_server.id,
18
18
  id: shard.id,
19
- env: shard.database_server.guard_rail_environment
19
+ env: ::Rails.version < '7.0' ? @shard_host.pool.connection_klass&.current_role : @shard_host.pool.connection_class&.current_role
20
20
  }
21
21
  end
22
22
  super name, payload
@@ -3,20 +3,19 @@
3
3
  module Switchman
4
4
  module StandardError
5
5
  def initialize(*args)
6
- # Shard.current can throw this when switchman isn't working right; if we try to
7
- # do our stuff here, it'll cause a SystemStackError, which is a pain to deal with
8
- if is_a?(::ActiveRecord::ConnectionNotEstablished)
9
- super
10
- return
11
- end
6
+ super
7
+ # These seem to get themselves into a bad state if we try to lookup shards while processing
8
+ return if is_a?(IO::EAGAINWaitReadable)
9
+
10
+ return if Thread.current[:switchman_error_handler]
12
11
 
13
12
  begin
14
- @active_shards = Shard.active_shards if defined?(Shard)
15
- rescue ::ActiveRecord::ConnectionNotEstablished
16
- # If we hit an error really early in boot, activerecord may not be initialized yet
17
- end
13
+ Thread.current[:switchman_error_handler] = true
18
14
 
19
- super
15
+ @active_shards ||= Shard.active_shards
16
+ ensure
17
+ Thread.current[:switchman_error_handler] = nil
18
+ end
20
19
  end
21
20
 
22
21
  def current_shard(klass = ::ActiveRecord::Base)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Switchman
4
4
  class UnshardedRecord < ::ActiveRecord::Base
5
- sharded_model
5
+ self.abstract_class = true
6
6
  end
7
7
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Switchman
4
- VERSION = '3.0.9'
4
+ VERSION = '3.1.0'
5
5
  end
data/lib/switchman.rb CHANGED
@@ -1,8 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'guard_rail'
4
- require 'switchman/open4'
5
- require 'switchman/engine'
4
+ require 'zeitwerk'
5
+
6
+ class SwitchmanInflector < Zeitwerk::GemInflector
7
+ def camelize(basename, abspath)
8
+ if basename =~ /\Apostgresql_(.*)/
9
+ 'PostgreSQL' + super($1, abspath)
10
+ else
11
+ super
12
+ end
13
+ end
14
+ end
15
+
16
+ loader = Zeitwerk::Loader.for_gem
17
+ loader.inflector = SwitchmanInflector.new(__FILE__)
18
+ loader.setup
6
19
 
7
20
  module Switchman
8
21
  def self.config
@@ -18,5 +31,12 @@ module Switchman
18
31
  @cache = cache
19
32
  end
20
33
 
34
+ def self.foreign_key_check(name, type, limit: nil)
35
+ puts "WARNING: All foreign keys need to be 8-byte integers. #{name} looks like a foreign key. If so, please add the option: `:limit => 8`" if name.to_s =~ /_id\z/ && type.to_s == 'integer' && limit.to_i < 8
36
+ end
37
+
21
38
  class OrderOnMultiShardQuery < RuntimeError; end
22
39
  end
40
+
41
+ # Load the engine and everything associated at gem load time
42
+ Switchman::Engine
@@ -1,5 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # In rails 7.0+ if you have only 1 db in the env it doesn't try to do explicit activation
4
+ # (and for rails purposes we only have one db per env because each database server is a separate env)
5
+ if Rails.version < '7.0'
6
+ task_prefix = ::Rake::Task.task_defined?('app:db:migrate') ? 'app:db' : 'db'
7
+ ::Rake::Task["#{task_prefix}:migrate"].clear_actions.enhance do
8
+ ::ActiveRecord::Tasks::DatabaseTasks.migrate
9
+ # Ensure this doesn't blow up when running inside the dummy app
10
+ Rake::Task["#{task_prefix}:_dump"].invoke
11
+ end
12
+ end
13
+
3
14
  module Switchman
4
15
  module Rake
5
16
  def self.filter_database_servers(&block)
@@ -35,9 +46,9 @@ module Switchman
35
46
 
36
47
  scope = base_scope.order(::Arel.sql('database_server_id IS NOT NULL, database_server_id, id'))
37
48
  if servers != DatabaseServer.all
38
- conditions = ['database_server_id IN (?)', servers.map(&:id)]
39
- conditions.first << ' OR database_server_id IS NULL' if servers.include?(Shard.default.database_server)
40
- scope = scope.where(conditions)
49
+ database_server_ids = servers.map(&:id)
50
+ database_server_ids << nil if servers.include?(Shard.default.database_server)
51
+ scope = scope.where(database_server_id: database_server_ids)
41
52
  end
42
53
 
43
54
  scope = shard_scope(scope, shard) if shard
@@ -50,9 +61,7 @@ module Switchman
50
61
  end
51
62
 
52
63
  # classes - an array or proc, to activate as the current shard during the
53
- # task. tasks which modify the schema may want to pass all categories in
54
- # so that schema updates for non-default tables happen against all shards.
55
- # this is handled automatically for the default migration tasks, below.
64
+ # task.
56
65
  def self.shardify_task(task_name, classes: [::ActiveRecord::Base])
57
66
  old_task = ::Rake::Task[task_name]
58
67
  old_actions = old_task.actions.dup
@@ -77,10 +86,8 @@ module Switchman
77
86
  nil
78
87
  end
79
88
  rescue => e
80
- puts "Exception from #{e.current_shard.id}: #{e.current_shard.description}" if options[:parallel] != 0
89
+ warn "Exception from #{e.current_shard.id}: #{e.current_shard.description}:\n#{e.full_message}" if options[:parallel] != 0
81
90
  raise
82
-
83
- # ::ActiveRecord::Base.configurations = old_configurations
84
91
  end
85
92
  end
86
93
  end
metadata CHANGED
@@ -1,16 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: switchman
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.9
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cody Cutrer
8
8
  - James Williams
9
9
  - Jacob Fugal
10
- autorequire:
10
+ autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2022-03-09 00:00:00.000000000 Z
13
+ date: 2022-06-02 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activerecord
@@ -21,7 +21,7 @@ dependencies:
21
21
  version: 6.1.4
22
22
  - - "<"
23
23
  - !ruby/object:Gem::Version
24
- version: '6.2'
24
+ version: '7.1'
25
25
  type: :runtime
26
26
  prerelease: false
27
27
  version_requirements: !ruby/object:Gem::Requirement
@@ -31,35 +31,35 @@ dependencies:
31
31
  version: 6.1.4
32
32
  - - "<"
33
33
  - !ruby/object:Gem::Version
34
- version: '6.2'
34
+ version: '7.1'
35
35
  - !ruby/object:Gem::Dependency
36
36
  name: guardrail
37
37
  requirement: !ruby/object:Gem::Requirement
38
38
  requirements:
39
39
  - - "~>"
40
40
  - !ruby/object:Gem::Version
41
- version: 3.0.0
41
+ version: 3.0.1
42
42
  type: :runtime
43
43
  prerelease: false
44
44
  version_requirements: !ruby/object:Gem::Requirement
45
45
  requirements:
46
46
  - - "~>"
47
47
  - !ruby/object:Gem::Version
48
- version: 3.0.0
48
+ version: 3.0.1
49
49
  - !ruby/object:Gem::Dependency
50
- name: open4
50
+ name: parallel
51
51
  requirement: !ruby/object:Gem::Requirement
52
52
  requirements:
53
53
  - - "~>"
54
54
  - !ruby/object:Gem::Version
55
- version: 1.3.0
55
+ version: '1.22'
56
56
  type: :runtime
57
57
  prerelease: false
58
58
  version_requirements: !ruby/object:Gem::Requirement
59
59
  requirements:
60
60
  - - "~>"
61
61
  - !ruby/object:Gem::Version
62
- version: 1.3.0
62
+ version: '1.22'
63
63
  - !ruby/object:Gem::Dependency
64
64
  name: railties
65
65
  requirement: !ruby/object:Gem::Requirement
@@ -69,7 +69,7 @@ dependencies:
69
69
  version: '6.1'
70
70
  - - "<"
71
71
  - !ruby/object:Gem::Version
72
- version: '6.2'
72
+ version: '7.1'
73
73
  type: :runtime
74
74
  prerelease: false
75
75
  version_requirements: !ruby/object:Gem::Requirement
@@ -79,7 +79,7 @@ dependencies:
79
79
  version: '6.1'
80
80
  - - "<"
81
81
  - !ruby/object:Gem::Version
82
- version: '6.2'
82
+ version: '7.1'
83
83
  - !ruby/object:Gem::Dependency
84
84
  name: appraisal
85
85
  requirement: !ruby/object:Gem::Requirement
@@ -242,8 +242,6 @@ extensions: []
242
242
  extra_rdoc_files: []
243
243
  files:
244
244
  - Rakefile
245
- - app/models/switchman/shard.rb
246
- - app/models/switchman/unsharded_record.rb
247
245
  - db/migrate/20130328212039_create_switchman_shards.rb
248
246
  - db/migrate/20130328224244_create_default_shard.rb
249
247
  - db/migrate/20161206323434_add_back_default_string_limits_switchman.rb
@@ -253,7 +251,7 @@ files:
253
251
  - lib/switchman.rb
254
252
  - lib/switchman/action_controller/caching.rb
255
253
  - lib/switchman/active_record/abstract_adapter.rb
256
- - lib/switchman/active_record/association.rb
254
+ - lib/switchman/active_record/associations.rb
257
255
  - lib/switchman/active_record/attribute_methods.rb
258
256
  - lib/switchman/active_record/base.rb
259
257
  - lib/switchman/active_record/calculations.rb
@@ -287,12 +285,14 @@ files:
287
285
  - lib/switchman/errors.rb
288
286
  - lib/switchman/guard_rail.rb
289
287
  - lib/switchman/guard_rail/relation.rb
290
- - lib/switchman/open4.rb
288
+ - lib/switchman/parallel.rb
291
289
  - lib/switchman/r_spec_helper.rb
292
290
  - lib/switchman/rails.rb
291
+ - lib/switchman/shard.rb
293
292
  - lib/switchman/sharded_instrumenter.rb
294
293
  - lib/switchman/standard_error.rb
295
294
  - lib/switchman/test_helper.rb
295
+ - lib/switchman/unsharded_record.rb
296
296
  - lib/switchman/version.rb
297
297
  - lib/tasks/switchman.rake
298
298
  homepage: http://www.instructure.com/
@@ -300,7 +300,7 @@ licenses:
300
300
  - MIT
301
301
  metadata:
302
302
  rubygems_mfa_required: 'true'
303
- post_install_message:
303
+ post_install_message:
304
304
  rdoc_options: []
305
305
  require_paths:
306
306
  - lib
@@ -308,15 +308,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
308
308
  requirements:
309
309
  - - ">="
310
310
  - !ruby/object:Gem::Version
311
- version: '2.6'
311
+ version: '2.7'
312
312
  required_rubygems_version: !ruby/object:Gem::Requirement
313
313
  requirements:
314
314
  - - ">="
315
315
  - !ruby/object:Gem::Version
316
316
  version: '0'
317
317
  requirements: []
318
- rubygems_version: 3.1.4
319
- signing_key:
318
+ rubygems_version: 3.1.6
319
+ signing_key:
320
320
  specification_version: 4
321
321
  summary: Rails sharding magic
322
322
  test_files: []