switchman 3.0.1 → 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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +16 -15
  3. data/db/migrate/20180828183945_add_default_shard_index.rb +2 -2
  4. data/db/migrate/20180828192111_add_timestamps_to_shards.rb +1 -1
  5. data/db/migrate/20190114212900_add_unique_name_indexes.rb +10 -4
  6. data/lib/switchman/action_controller/caching.rb +2 -2
  7. data/lib/switchman/active_record/abstract_adapter.rb +11 -18
  8. data/lib/switchman/active_record/associations.rb +315 -0
  9. data/lib/switchman/active_record/attribute_methods.rb +191 -79
  10. data/lib/switchman/active_record/base.rb +204 -50
  11. data/lib/switchman/active_record/calculations.rb +93 -50
  12. data/lib/switchman/active_record/connection_handler.rb +18 -0
  13. data/lib/switchman/active_record/connection_pool.rb +47 -34
  14. data/lib/switchman/active_record/database_configurations.rb +32 -6
  15. data/lib/switchman/active_record/finder_methods.rb +22 -16
  16. data/lib/switchman/active_record/log_subscriber.rb +3 -6
  17. data/lib/switchman/active_record/migration.rb +42 -14
  18. data/lib/switchman/active_record/model_schema.rb +1 -1
  19. data/lib/switchman/active_record/pending_migration_connection.rb +17 -0
  20. data/lib/switchman/active_record/persistence.rb +37 -2
  21. data/lib/switchman/active_record/postgresql_adapter.rb +39 -20
  22. data/lib/switchman/active_record/predicate_builder.rb +2 -2
  23. data/lib/switchman/active_record/query_cache.rb +26 -17
  24. data/lib/switchman/active_record/query_methods.rb +252 -135
  25. data/lib/switchman/active_record/reflection.rb +10 -3
  26. data/lib/switchman/active_record/relation.rb +154 -32
  27. data/lib/switchman/active_record/spawn_methods.rb +3 -7
  28. data/lib/switchman/active_record/statement_cache.rb +13 -9
  29. data/lib/switchman/active_record/table_definition.rb +1 -1
  30. data/lib/switchman/active_record/tasks/database_tasks.rb +6 -1
  31. data/lib/switchman/active_record/test_fixtures.rb +89 -0
  32. data/lib/switchman/active_support/cache.rb +25 -4
  33. data/lib/switchman/arel.rb +20 -7
  34. data/lib/switchman/call_super.rb +2 -2
  35. data/lib/switchman/database_server.rb +123 -83
  36. data/lib/switchman/default_shard.rb +14 -5
  37. data/lib/switchman/engine.rb +85 -131
  38. data/lib/switchman/environment.rb +2 -2
  39. data/lib/switchman/errors.rb +17 -2
  40. data/lib/switchman/guard_rail/relation.rb +7 -10
  41. data/lib/switchman/guard_rail.rb +5 -0
  42. data/lib/switchman/parallel.rb +68 -0
  43. data/lib/switchman/r_spec_helper.rb +17 -28
  44. data/lib/switchman/rails.rb +1 -4
  45. data/{app/models → lib}/switchman/shard.rb +229 -246
  46. data/lib/switchman/sharded_instrumenter.rb +9 -3
  47. data/lib/switchman/shared_schema_cache.rb +11 -0
  48. data/lib/switchman/standard_error.rb +15 -12
  49. data/lib/switchman/test_helper.rb +3 -3
  50. data/{app/models → lib}/switchman/unsharded_record.rb +1 -1
  51. data/lib/switchman/version.rb +1 -1
  52. data/lib/switchman.rb +46 -12
  53. data/lib/tasks/switchman.rake +101 -54
  54. metadata +34 -176
  55. data/lib/switchman/active_record/association.rb +0 -206
  56. data/lib/switchman/open4.rb +0 -80
@@ -3,7 +3,7 @@
3
3
  module Switchman
4
4
  class ShardedInstrumenter < ::SimpleDelegator
5
5
  def initialize(instrumenter, shard_host)
6
- super instrumenter
6
+ super(instrumenter)
7
7
  @shard_host = shard_host
8
8
  end
9
9
 
@@ -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.database_server.guard_rail_environment
25
+ env:
20
26
  }
21
27
  end
22
- super name, payload
28
+ super
23
29
  end
24
30
  end
25
31
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Switchman
4
+ class SharedSchemaCache
5
+ def self.get_schema_cache(connection)
6
+ @schema_cache ||= ::ActiveRecord::ConnectionAdapters::SchemaCache.new(connection)
7
+ @schema_cache.connection = connection
8
+ @schema_cache
9
+ end
10
+ end
11
+ end
@@ -3,20 +3,23 @@
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)
12
9
 
13
- if defined?(Shard)
14
- @active_shards = Shard.sharded_models.map do |klass|
15
- [klass, Shard.current(klass)]
16
- end.compact.to_h
17
- end
10
+ return if Thread.current[:switchman_error_handler]
18
11
 
19
- super
12
+ begin
13
+ Thread.current[:switchman_error_handler] = true
14
+
15
+ @active_shards ||= Shard.active_shards
16
+ rescue
17
+ # intentionally empty - don't allow calculating the active_shards to prevent
18
+ # creating the StandardError for any reason. this prevents various random issues
19
+ # when a StandardError is created within a finalizer
20
+ ensure
21
+ Thread.current[:switchman_error_handler] = nil
22
+ end
20
23
  end
21
24
 
22
25
  def current_shard(klass = ::ActiveRecord::Base)
@@ -19,7 +19,7 @@ module Switchman
19
19
  end
20
20
 
21
21
  server1 = Shard.default.database_server
22
- server2 = DatabaseServer.create(Shard.default.database_server.config.merge(server2: true))
22
+ server2 = DatabaseServer.create(Shard.default.database_server.config.merge(server2: true, schema_dump: false))
23
23
 
24
24
  if server1 == Shard.default.database_server && server1.config[:shard1] && server1.config[:shard2]
25
25
  # look for the shards in the db already
@@ -63,9 +63,9 @@ 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
- shard = Shard.where('database_server_id IS NOT NULL AND name=?', name).first
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
70
70
  shard.database_server = server if shard
71
71
  shard
@@ -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.1'
4
+ VERSION = "4.2.5"
5
5
  end
data/lib/switchman.rb CHANGED
@@ -1,20 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'guard_rail'
4
- require 'switchman/open4'
5
- require 'switchman/engine'
3
+ require "guard_rail"
4
+ require "zeitwerk"
6
5
 
7
- module Switchman
8
- def self.config
9
- # TODO: load from yaml
10
- @config ||= {}
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
11
13
  end
14
+ end
12
15
 
13
- def self.cache
14
- (@cache.respond_to?(:call) ? @cache.call : @cache) || ::Rails.cache
15
- end
16
+ loader = Zeitwerk::Loader.for_gem
17
+ loader.inflector = SwitchmanInflector.new(__FILE__)
18
+ loader.setup
19
+
20
+ module Switchman
21
+ Deprecation = ::ActiveSupport::Deprecation.new("4.0", "Switchman")
22
+
23
+ class << self
24
+ attr_writer :cache
16
25
 
17
- def self.cache=(cache)
18
- @cache = cache
26
+ def config
27
+ # TODO: load from yaml
28
+ @config ||= {}
29
+ end
30
+
31
+ def cache
32
+ (@cache.respond_to?(:call) ? @cache.call : @cache) || ::Rails.cache
33
+ end
34
+
35
+ def region
36
+ config[:region]
37
+ end
38
+
39
+ def foreign_key_check(name, type, limit: nil)
40
+ return unless name.to_s.end_with?("_id") && type.to_s == "integer" && limit.to_i < 8
41
+
42
+ puts <<~TEXT.squish
43
+ WARNING: All foreign keys need to be 8-byte integers.
44
+ #{name} looks like a foreign key.
45
+ If so, please add the option: `:limit => 8`
46
+ TEXT
47
+ end
19
48
  end
49
+
50
+ class OrderOnMultiShardQuery < RuntimeError; end
20
51
  end
52
+
53
+ # Load the engine and everything associated at gem load time
54
+ Switchman::Engine
@@ -2,28 +2,29 @@
2
2
 
3
3
  module Switchman
4
4
  module Rake
5
- def self.filter_database_servers(&block)
6
- chain = filter_database_servers_chain # use a local variable so that the current chain is closed over in the following lambda
7
- @filter_database_servers_chain = ->(servers) { block.call(servers, chain) }
5
+ def self.filter_database_servers
6
+ # use a local variable so that the current chain is closed over in the following lambda
7
+ chain = filter_database_servers_chain
8
+ @filter_database_servers_chain = ->(servers) { yield(servers, chain) }
8
9
  end
9
10
 
10
11
  def self.scope(base_scope = Shard,
11
- database_server: ENV['DATABASE_SERVER'],
12
- shard: ENV['SHARD'])
12
+ database_server: ENV.fetch("DATABASE_SERVER", nil),
13
+ shard: ENV.fetch("SHARD", nil))
13
14
  servers = DatabaseServer.all
14
15
 
15
16
  if database_server
16
17
  servers = database_server
17
- if servers.first == '-'
18
+ if servers.first == "-"
18
19
  negative = true
19
20
  servers = servers[1..]
20
21
  end
21
- servers = servers.split(',')
22
- open = servers.delete('open')
22
+ servers = servers.split(",")
23
+ open = servers.delete("open")
23
24
 
24
- servers = servers.map { |server| DatabaseServer.find(server) }.compact
25
+ servers = servers.filter_map { |server| DatabaseServer.find(server) }
25
26
  if open
26
- open_servers = DatabaseServer.all.select { |server| server.config[:open] }
27
+ open_servers = DatabaseServer.select { |server| server.config[:open] }
27
28
  servers.concat(open_servers)
28
29
  servers << DatabaseServer.find(nil) if open_servers.empty?
29
30
  servers.uniq!
@@ -31,13 +32,26 @@ module Switchman
31
32
  servers = DatabaseServer.all - servers if negative
32
33
  end
33
34
 
35
+ ENV["REGION"]&.split(",")&.each do |region|
36
+ method = :select!
37
+ if region[0] == "-"
38
+ method = :reject!
39
+ region = region[1..]
40
+ end
41
+ if region == "self"
42
+ servers.send(method, &:in_current_region?)
43
+ else
44
+ servers.send(method) { |server| server.in_region?(region) }
45
+ end
46
+ end
47
+
34
48
  servers = filter_database_servers_chain.call(servers)
35
49
 
36
- scope = base_scope.order(::Arel.sql('database_server_id IS NOT NULL, database_server_id, id'))
50
+ scope = base_scope.order(::Arel.sql("database_server_id IS NOT NULL, database_server_id, id"))
37
51
  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)
52
+ database_server_ids = servers.map(&:id)
53
+ database_server_ids << nil if servers.include?(Shard.default.database_server)
54
+ scope = scope.where(database_server_id: database_server_ids)
41
55
  end
42
56
 
43
57
  scope = shard_scope(scope, shard) if shard
@@ -46,52 +60,93 @@ module Switchman
46
60
  end
47
61
 
48
62
  def self.options
49
- { parallel: ENV['PARALLEL'].to_i, max_procs: ENV['MAX_PARALLEL_PROCS'] }
63
+ { exception: (ENV["FAIL_FAST"] == "0") ? :defer : :raise, parallel: ENV["PARALLEL"].to_i }
50
64
  end
51
65
 
52
66
  # 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.
67
+ # task.
56
68
  def self.shardify_task(task_name, classes: [::ActiveRecord::Base])
69
+ log_format = ENV.fetch("LOG_FORMAT", nil)
57
70
  old_task = ::Rake::Task[task_name]
58
71
  old_actions = old_task.actions.dup
59
72
  old_task.actions.clear
60
73
 
61
74
  old_task.enhance do |*task_args|
62
75
  if ::Rails.env.test?
63
- require 'switchman/test_helper'
76
+ require "switchman/test_helper"
64
77
  TestHelper.recreate_persistent_test_shards(dont_create: true)
65
78
  end
66
79
 
67
80
  ::GuardRail.activate(:deploy) do
68
81
  Shard.default.database_server.unguard do
69
82
  classes = classes.call if classes.respond_to?(:call)
70
- Shard.with_each_shard(scope, classes, **options) do
83
+
84
+ # We don't want the shard status messages to be wrapped using a custom log transfomer
85
+ original_stderr = $stderr
86
+ original_stdout = $stdout
87
+ output = if log_format == "json"
88
+ lambda { |msg|
89
+ JSON.dump(shard: Shard.current.id,
90
+ database_server: Shard.current.database_server.id,
91
+ type: "log",
92
+ message: msg)
93
+ }
94
+ else
95
+ nil
96
+ end
97
+ Shard.with_each_shard(scope, classes, output:, **options) do
71
98
  shard = Shard.current
72
- puts "#{shard.id}: #{shard.description}"
99
+
100
+ if log_format == "json"
101
+ original_stdout.puts JSON.dump(
102
+ shard: shard.id,
103
+ database_server: shard.database_server.id,
104
+ type: "started"
105
+ )
106
+ else
107
+ original_stdout.puts "#{shard.id}: #{shard.description}"
108
+ end
73
109
 
74
110
  shard.database_server.unguard do
75
111
  old_actions.each { |action| action.call(*task_args) }
76
112
  end
113
+
114
+ if log_format == "json"
115
+ original_stdout.puts JSON.dump(
116
+ shard: shard.id,
117
+ database_server: shard.database_server.id,
118
+ type: "completed"
119
+ )
120
+ end
77
121
  nil
122
+ rescue => e
123
+ if log_format == "json"
124
+ original_stderr.puts JSON.dump(
125
+ shard: shard.id,
126
+ database_server: shard.database_server.id,
127
+ type: "failed",
128
+ message: e.full_message
129
+ )
130
+ end
131
+
132
+ raise
78
133
  end
79
134
  rescue => e
80
- puts "Exception from #{e.current_shard.id}: #{e.current_shard.description}" if options[:parallel] != 0
135
+ if options[:parallel] != 0
136
+ warn "Exception from #{e.current_shard.id}: #{e.current_shard.description}:\n#{e.full_message}"
137
+ end
81
138
  raise
82
-
83
- #::ActiveRecord::Base.configurations = old_configurations
84
139
  end
85
140
  end
86
141
  end
87
142
  end
88
143
 
89
144
  %w[db:migrate db:migrate:up db:migrate:down db:rollback].each do |task_name|
90
- shardify_task(task_name, classes: -> { Shard.sharded_models })
145
+ shardify_task(task_name)
91
146
  end
92
147
 
93
148
  def self.shard_scope(scope, raw_shard_ids)
94
- raw_shard_ids = raw_shard_ids.split(',')
149
+ raw_shard_ids = raw_shard_ids.split(",")
95
150
 
96
151
  shard_ids = []
97
152
  negative_shard_ids = []
@@ -101,13 +156,13 @@ module Switchman
101
156
 
102
157
  raw_shard_ids.each do |id|
103
158
  case id
104
- when 'default'
159
+ when "default"
105
160
  shard_ids << Shard.default.id
106
- when '-default'
161
+ when "-default"
107
162
  negative_shard_ids << Shard.default.id
108
- when 'primary'
163
+ when "primary"
109
164
  shard_ids.concat(Shard.primary.pluck(:id))
110
- when '-primary'
165
+ when "-primary"
111
166
  negative_shard_ids.concat(Shard.primary.pluck(:id))
112
167
  when /^(-?)(\d+)?\.\.(\.)?(\d+)?$/
113
168
  negative, start, open, finish = $1.present?, $2, $3.present?, $4
@@ -115,8 +170,8 @@ module Switchman
115
170
 
116
171
  range = []
117
172
  range << "id>=#{start}" if start
118
- range << "id<#{'=' unless open}#{finish}" if finish
119
- (negative ? negative_ranges : ranges) << "(#{range.join(' AND ')})"
173
+ range << "id<#{"=" unless open}#{finish}" if finish
174
+ (negative ? negative_ranges : ranges) << "(#{range.join(" AND ")})"
120
175
  when /^-(\d+)$/
121
176
  negative_shard_ids << $1.to_i
122
177
  when /^\d+$/
@@ -144,21 +199,21 @@ module Switchman
144
199
  select = []
145
200
  if index != 1
146
201
  subscope = subscope.offset(per_chunk * (index - 1))
147
- select << 'MIN(id) AS min_id'
202
+ select << "MIN(id) AS min_id"
148
203
  end
149
204
  if index != denominator
150
205
  subscope = subscope.limit(per_chunk)
151
- select << 'MAX(id) AS max_id'
206
+ select << "MAX(id) AS max_id"
152
207
  end
153
208
 
154
- result = Shard.from(subscope).select(select.join(', ')).to_a.first
209
+ result = Shard.from(subscope).select(select.join(", ")).to_a.first
155
210
  range = case index
156
211
  when 1
157
- "id<=#{result['max_id']}"
212
+ "id<=#{result["max_id"]}"
158
213
  when denominator
159
- "id>=#{result['min_id']}"
214
+ "id>=#{result["min_id"]}"
160
215
  else
161
- "(id>=#{result['min_id']} AND id<=#{result['max_id']})"
216
+ "(id>=#{result["min_id"]} AND id<=#{result["max_id"]})"
162
217
  end
163
218
 
164
219
  (numerator.negative? ? negative_ranges : ranges) << range
@@ -179,16 +234,16 @@ module Switchman
179
234
 
180
235
  conditions = []
181
236
  positive_queries = []
182
- positive_queries << ranges.join(' OR ') unless ranges.empty?
237
+ positive_queries << ranges.join(" OR ") unless ranges.empty?
183
238
  unless shard_ids.empty?
184
- positive_queries << 'id IN (?)'
239
+ positive_queries << "id IN (?)"
185
240
  conditions << shard_ids
186
241
  end
187
- positive_query = positive_queries.join(' OR ')
242
+ positive_query = positive_queries.join(" OR ")
188
243
  scope = scope.where(positive_query, *conditions) unless positive_queries.empty?
189
244
 
190
- scope = scope.where("NOT (#{negative_ranges.join(' OR')})") unless negative_ranges.empty?
191
- scope = scope.where('id NOT IN (?)', negative_shard_ids) unless negative_shard_ids.empty?
245
+ scope = scope.where("NOT (#{negative_ranges.join(" OR")})") unless negative_ranges.empty?
246
+ scope = scope.where("id NOT IN (?)", negative_shard_ids) unless negative_shard_ids.empty?
192
247
  scope
193
248
  end
194
249
 
@@ -199,17 +254,9 @@ module Switchman
199
254
 
200
255
  module ActiveRecord
201
256
  module PostgreSQLDatabaseTasks
202
- def structure_dump(filename, extra_flags = nil)
203
- set_psql_env
204
- args = ['-s', '-x', '-O', '-f', filename]
205
- args.concat(Array(extra_flags)) if extra_flags
206
- shard = Shard.current.name
207
- serialized_search_path = shard
208
- args << "--schema=#{Shellwords.escape(shard)}"
209
-
210
- args << configuration['database']
211
- run_cmd('pg_dump', args, 'dumping')
212
- File.open(filename, 'a') { |f| f << "SET search_path TO #{serialized_search_path};\n\n" }
257
+ def structure_dump(...)
258
+ ::ActiveRecord.dump_schemas = Switchman::Shard.current.name
259
+ super
213
260
  end
214
261
  end
215
262
  end