switchman 3.0.8 → 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 +23 -14
  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 +27 -128
  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 +17 -17
  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,14 +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
- rescue ::ActiveRecord::ConnectionNotEstablished
47
- nil
48
- # rescue the fake default if the table doesn't exist
49
40
  rescue
50
41
  default
51
42
  end
52
- return default unless @default
53
43
 
54
44
  # make sure this is not erroneously cached
55
45
  @default.database_server.remove_instance_variable(:@primary_shard) if @default.database_server.instance_variable_defined?(:@primary_shard)
@@ -127,8 +117,7 @@ module Switchman
127
117
  # * +classes+ - an array of classes to activate
128
118
  # parallel: - true/false to execute in parallel, or an integer of how many
129
119
  # sub-processes. Note that parallel invocation currently uses
130
- # forking, so should be used sparingly because you cannot get
131
- # results back
120
+ # forking.
132
121
  # exception: - :ignore, :raise, :defer (wait until the end and raise the first
133
122
  # error), or a proc
134
123
  def with_each_shard(*args, parallel: false, exception: :raise, &block)
@@ -161,137 +150,47 @@ module Switchman
161
150
  return if database_servers.count.zero?
162
151
 
163
152
  scopes = database_servers.to_h do |server|
164
- [server, server.shards.merge(scope)]
153
+ [server, scope.merge(server.shards)]
165
154
  end
166
155
  else
167
156
  scopes = scope.group_by(&:database_server)
168
157
  end
169
158
 
170
- exception_pipes = []
171
- pids = []
172
- out_fds = []
173
- err_fds = []
174
- pid_to_name_map = {}
175
- fd_to_name_map = {}
176
- errors = []
177
-
178
- wait_for_output = lambda do
179
- ready, = IO.select(out_fds + err_fds)
180
- ready.each do |fd|
181
- if fd.eof?
182
- fd.close
183
- out_fds.delete(fd)
184
- err_fds.delete(fd)
185
- next
186
- end
187
- line = fd.readline
188
- puts "#{fd_to_name_map[fd]}: #{line}"
189
- end
190
- end
191
-
192
- # only one process; don't bother forking
193
- return with_each_shard(scopes.first.last, classes, exception: exception, &block) if scopes.length == 1
194
-
195
159
  # clear connections prior to forking (no more queries will be executed in the parent,
196
160
  # and we want them gone so that we don't accidentally use them post-fork doing something
197
161
  # silly like dealloc'ing prepared statements)
198
162
  ::ActiveRecord::Base.clear_all_connections!
199
163
 
200
- 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|
201
166
  name = server.id
202
-
203
- exception_pipe = IO.pipe
204
- exception_pipes << exception_pipe
205
- pid, io_in, io_out, io_err = Open4.pfork4(lambda do
206
- Switchman.config[:on_fork_proc]&.call
207
-
208
- # set a pretty name for the process title, up to 128 characters
209
- # (we don't actually know the limit, depending on how the process
210
- # was started)
211
- # first, simplify the binary name by stripping directories,
212
- # then truncate arguments as necessary
213
- bin = File.basename($0) # Process.argv0 doesn't work on Ruby 2.5 (https://bugs.ruby-lang.org/issues/15887)
214
- max_length = 128 - bin.length - name.length - 3
215
- args = ARGV.join(' ')
216
- args = args[0..max_length] if max_length >= 0
217
- 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(' ')
218
175
  Process.setproctitle(new_title)
219
-
220
- with_each_shard(subscope, classes, exception: exception, &block)
221
- exception_pipe.last.close
222
- rescue Exception => e # rubocop:disable Lint/RescueException
223
- begin
224
- dumped = Marshal.dump(e)
225
- dumped = nil if dumped.length > 64 * 1024
226
- rescue
227
- dumped = nil
228
- end
229
-
230
- if dumped.nil?
231
- # couldn't dump the exception; create a copy with just
232
- # the message and the backtrace
233
- e2 = e.class.new(e.message)
234
- backtrace = e.backtrace
235
- # truncate excessively long backtraces
236
- backtrace = backtrace[0...25] + ['...'] + backtrace[-25..] if backtrace.length > 50
237
- e2.set_backtrace(backtrace)
238
- e2.instance_variable_set(:@active_shards, e.instance_variable_get(:@active_shards))
239
- dumped = Marshal.dump(e2)
240
- end
241
- exception_pipe.last.set_encoding(dumped.encoding)
242
- exception_pipe.last.write(dumped)
243
- exception_pipe.last.flush
244
- exception_pipe.last.close
245
- exit! 1
246
- end)
247
- exception_pipe.last.close
248
- pids << pid
249
- io_in.close # don't care about writing to stdin
250
- out_fds << io_out
251
- err_fds << io_err
252
- pid_to_name_map[pid] = name
253
- fd_to_name_map[io_out] = name
254
- fd_to_name_map[io_err] = name
255
-
256
- while pids.count >= parallel
257
- while out_fds.count >= parallel
258
- # wait for output if we've hit the parallel limit
259
- wait_for_output.call
260
- end
261
- # we've gotten all the output from one fd so wait for its child process to exit
262
- found_pid, status = Process.wait2
263
- pids.delete(found_pid)
264
- 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))
265
181
  end
266
- # we've gotten all the output from one fd so wait for its child process to exit
267
- found_pid, status = Process.wait2
268
- pids.delete(found_pid)
269
- errors << pid_to_name_map[found_pid] if status.exitstatus != 0
270
- end
271
-
272
- wait_for_output.call while out_fds.any? || err_fds.any?
273
- pids.each do |pid|
274
- _, status = Process.waitpid2(pid)
275
- errors << pid_to_name_map[pid] if status.exitstatus != 0
276
- end
277
-
278
- # check for an exception; we only re-raise the first one
279
- exception_pipes.each do |exception_pipe|
280
- serialized_exception = exception_pipe.first.read
281
- next if serialized_exception.empty?
282
-
283
- ex = Marshal.load(serialized_exception) # rubocop:disable Security/MarshalLoad
284
- raise ex
285
- ensure
286
- exception_pipe.first.close
287
- end
182
+ end.flatten
288
183
 
184
+ errors = ret.select { |val| val.is_a?(Parallel::QuietExceptionWrapper) }
289
185
  unless errors.empty?
290
- raise ParallelShardExecError,
291
- "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
292
191
  end
293
192
 
294
- return
193
+ return ret.map(&:result)
295
194
  end
296
195
 
297
196
  classes ||= []
@@ -471,10 +370,10 @@ module Switchman
471
370
 
472
371
  def add_sharded_model(klass)
473
372
  @sharded_models = (sharded_models + [klass]).freeze
474
- initialize_sharding
373
+ configure_connects_to
475
374
  end
476
375
 
477
- def initialize_sharding
376
+ def configure_connects_to
478
377
  full_connects_to_hash = DatabaseServer.all.to_h { |db| [db.id.to_sym, db.connects_to_hash] }
479
378
  sharded_models.each do |klass|
480
379
  connects_to_hash = full_connects_to_hash.deep_dup
@@ -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.8'
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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: switchman
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.8
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cody Cutrer
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2022-02-23 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/
@@ -308,14 +308,14 @@ 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
318
+ rubygems_version: 3.1.6
319
319
  signing_key:
320
320
  specification_version: 4
321
321
  summary: Rails sharding magic