switchman 3.0.24 → 4.2.4

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +16 -15
  3. data/db/migrate/20180828183945_add_default_shard_index.rb +1 -1
  4. data/db/migrate/20190114212900_add_unique_name_indexes.rb +10 -4
  5. data/lib/switchman/active_record/abstract_adapter.rb +11 -7
  6. data/lib/switchman/active_record/associations.rb +157 -50
  7. data/lib/switchman/active_record/attribute_methods.rb +192 -101
  8. data/lib/switchman/active_record/base.rb +136 -33
  9. data/lib/switchman/active_record/calculations.rb +91 -48
  10. data/lib/switchman/active_record/connection_handler.rb +18 -0
  11. data/lib/switchman/active_record/connection_pool.rb +41 -6
  12. data/lib/switchman/active_record/database_configurations.rb +23 -13
  13. data/lib/switchman/active_record/finder_methods.rb +22 -16
  14. data/lib/switchman/active_record/log_subscriber.rb +3 -6
  15. data/lib/switchman/active_record/migration.rb +42 -17
  16. data/lib/switchman/active_record/model_schema.rb +1 -1
  17. data/lib/switchman/active_record/pending_migration_connection.rb +17 -0
  18. data/lib/switchman/active_record/persistence.rb +32 -2
  19. data/lib/switchman/active_record/postgresql_adapter.rb +37 -22
  20. data/lib/switchman/active_record/predicate_builder.rb +2 -2
  21. data/lib/switchman/active_record/query_cache.rb +26 -17
  22. data/lib/switchman/active_record/query_methods.rb +249 -142
  23. data/lib/switchman/active_record/reflection.rb +10 -3
  24. data/lib/switchman/active_record/relation.rb +103 -32
  25. data/lib/switchman/active_record/spawn_methods.rb +3 -7
  26. data/lib/switchman/active_record/statement_cache.rb +13 -9
  27. data/lib/switchman/active_record/table_definition.rb +1 -1
  28. data/lib/switchman/active_record/tasks/database_tasks.rb +6 -1
  29. data/lib/switchman/active_record/test_fixtures.rb +71 -25
  30. data/lib/switchman/active_support/cache.rb +9 -4
  31. data/lib/switchman/arel.rb +16 -25
  32. data/lib/switchman/call_super.rb +2 -2
  33. data/lib/switchman/database_server.rb +68 -34
  34. data/lib/switchman/default_shard.rb +14 -3
  35. data/lib/switchman/engine.rb +36 -19
  36. data/lib/switchman/environment.rb +2 -2
  37. data/lib/switchman/errors.rb +13 -0
  38. data/lib/switchman/guard_rail/relation.rb +3 -3
  39. data/lib/switchman/parallel.rb +6 -6
  40. data/lib/switchman/r_spec_helper.rb +12 -11
  41. data/lib/switchman/shard.rb +182 -72
  42. data/lib/switchman/sharded_instrumenter.rb +9 -3
  43. data/lib/switchman/shared_schema_cache.rb +11 -0
  44. data/lib/switchman/standard_error.rb +4 -0
  45. data/lib/switchman/test_helper.rb +3 -3
  46. data/lib/switchman/version.rb +1 -1
  47. data/lib/switchman.rb +27 -15
  48. data/lib/tasks/switchman.rake +96 -60
  49. metadata +18 -168
@@ -7,12 +7,12 @@ module Switchman
7
7
  # since all should point to the same data, even if multiple are writable
8
8
  # (Picks 'primary' since it is guaranteed to exist and switchman handles activating
9
9
  # deploy through other means)
10
- def configs_for(include_replicas: false, name: nil, **)
10
+ def configs_for(include_hidden: false, name: nil, **)
11
11
  res = super
12
- if name && !include_replicas
13
- return nil unless name.end_with?('primary')
14
- elsif !include_replicas
15
- return res.select { |config| config.name.end_with?('primary') }
12
+ if name && !include_hidden
13
+ return nil unless name.end_with?("primary")
14
+ elsif !include_hidden
15
+ return res.select { |config| config.name.end_with?("primary") }
16
16
  end
17
17
  res
18
18
  end
@@ -27,21 +27,31 @@ module Switchman
27
27
  return configs if configs.is_a?(Array)
28
28
 
29
29
  db_configs = configs.flat_map do |env_name, config|
30
- # It would be nice to do the auto-fallback that we want here, but we haven't
31
- # actually done that for years (or maybe ever) and it will be a big lift to get working
32
- roles = config.keys.select { |k| config[k].is_a?(Hash) || (config[k].is_a?(Array) && config[k].all? { |ck| ck.is_a?(Hash) }) }
33
- base_config = config.except(*roles)
30
+ if config.is_a?(Hash)
31
+ # It would be nice to do the auto-fallback that we want here, but we haven't
32
+ # actually done that for years (or maybe ever) and it will be a big lift to get working
33
+ roles = config.keys.select do |k|
34
+ config[k].is_a?(Hash) || (config[k].is_a?(Array) && config[k].all?(Hash))
35
+ end
36
+ base_config = config.except(*roles)
37
+ else
38
+ base_config = config
39
+ roles = []
40
+ end
34
41
 
35
42
  name = "#{env_name}/primary"
36
- name = 'primary' if env_name == default_env
43
+ name = "primary" if env_name == default_env
37
44
  base_db = build_db_config_from_raw_config(env_name, name, base_config)
38
45
  [base_db] + roles.map do |role|
39
- build_db_config_from_raw_config(env_name, "#{env_name}/#{role}",
40
- base_config.merge(config[role].is_a?(Array) ? config[role].first : config[role]))
46
+ build_db_config_from_raw_config(
47
+ env_name,
48
+ "#{env_name}/#{role}",
49
+ base_config.merge(config[role].is_a?(Array) ? config[role].first : config[role])
50
+ )
41
51
  end
42
52
  end
43
53
 
44
- db_configs << environment_url_config(default_env, 'primary', {}) unless db_configs.find(&:for_current_env?)
54
+ db_configs << environment_url_config(default_env, "primary", {}) unless db_configs.find(&:for_current_env?)
45
55
 
46
56
  merge_db_environment_variables(default_env, db_configs.compact)
47
57
  end
@@ -4,10 +4,10 @@ module Switchman
4
4
  module ActiveRecord
5
5
  module FinderMethods
6
6
  def find_one(id)
7
- return super(id) unless klass.integral_id?
7
+ return super unless klass.integral_id?
8
8
 
9
9
  if shard_source_value != :implicit
10
- current_shard = Shard.current(klass.connection_classes)
10
+ current_shard = Shard.current(klass.connection_class_for_self)
11
11
  result = activate do |relation, shard|
12
12
  current_id = Shard.relative_id_for(id, current_shard, shard)
13
13
  # current_id will be nil for non-integral id
@@ -28,14 +28,14 @@ module Switchman
28
28
  if shard
29
29
  shard.activate { super(local_id) }
30
30
  else
31
- super(id)
31
+ super
32
32
  end
33
33
  end
34
34
 
35
35
  def find_some_ordered(ids)
36
- current_shard = Shard.current(klass.connection_classes)
36
+ current_shard = Shard.current(klass.connection_class_for_self)
37
37
  ids = ids.map { |id| Shard.relative_id_for(id, current_shard, current_shard) }
38
- super(ids)
38
+ super
39
39
  end
40
40
 
41
41
  def find_or_instantiator_by_attributes(match, attributes, *args)
@@ -43,23 +43,29 @@ module Switchman
43
43
  end
44
44
 
45
45
  def exists?(conditions = :none)
46
- conditions = conditions.id if ::ActiveRecord::Base === conditions
47
- return false unless conditions
46
+ return false if @none
48
47
 
49
- relation = apply_join_dependency(eager_loading: false)
50
- return false if ::ActiveRecord::NullRelation === relation
48
+ if Base === conditions
49
+ raise ArgumentError, <<-TEXT.squish
50
+ You are passing an instance of ActiveRecord::Base to `exists?`.
51
+ Please pass the id of the object by calling `.id`.
52
+ TEXT
53
+ end
51
54
 
52
- relation = relation.except(:select, :order).select('1 AS one').limit(1)
55
+ return false if !conditions || limit_value == 0 # rubocop:disable Style/NumericPredicate
53
56
 
54
- case conditions
55
- when Array, Hash
56
- relation = relation.where(conditions)
57
- else
58
- relation = relation.where(table[primary_key].eq(conditions)) if conditions != :none
57
+ if eager_loading?
58
+ relation = apply_join_dependency(eager_loading: false)
59
+ return relation.exists?(conditions)
59
60
  end
60
61
 
62
+ relation = construct_relation_for_exists(conditions)
63
+ return false if relation.where_clause.contradiction?
64
+
61
65
  relation.activate do |shard_rel|
62
- return true if connection.select_value(shard_rel.arel, "#{name} Exists")
66
+ return true if skip_query_cache_if_necessary do
67
+ connection.select_rows(shard_rel.arel, "#{name} Exists?").size == 1
68
+ end
63
69
  end
64
70
  false
65
71
  end
@@ -5,29 +5,26 @@ module Switchman
5
5
  module LogSubscriber
6
6
  # sadly, have to completely replace this
7
7
  def sql(event)
8
- self.class.runtime += event.duration
9
- return unless logger.debug?
10
-
11
8
  payload = event.payload
12
9
 
13
10
  return if ::ActiveRecord::LogSubscriber::IGNORE_PAYLOAD_NAMES.include?(payload[:name])
14
11
 
15
12
  name = "#{payload[:name]} (#{event.duration.round(1)}ms)"
16
13
  name = "CACHE #{name}" if payload[:cached]
17
- sql = payload[:sql].squeeze(' ')
14
+ sql = payload[:sql].squeeze(" ")
18
15
  binds = nil
19
16
  shard = payload[:shard]
20
17
  shard = " [#{shard[:database_server_id]}:#{shard[:id]} #{shard[:env]}]" if shard
21
18
 
22
19
  unless (payload[:binds] || []).empty?
23
20
  casted_params = type_casted_binds(payload[:type_casted_binds])
24
- binds = ' ' + payload[:binds].zip(casted_params).map do |attr, value|
21
+ binds = " " + payload[:binds].zip(casted_params).map do |attr, value|
25
22
  render_bind(attr, value)
26
23
  end.inspect
27
24
  end
28
25
 
29
26
  name = colorize_payload_name(name, payload[:name])
30
- sql = color(sql, sql_color(sql), true)
27
+ sql = color(sql, sql_color(sql), bold: true)
31
28
 
32
29
  debug " #{name} #{sql}#{binds}#{shard}"
33
30
  end
@@ -14,41 +14,66 @@ module Switchman
14
14
 
15
15
  def connection
16
16
  conn = super
17
- ::ActiveRecord::Base.connection_pool.switch_database(conn) if conn.shard != ::ActiveRecord::Base.current_switchman_shard
17
+ if conn.shard != ::ActiveRecord::Base.current_switchman_shard
18
+ ::ActiveRecord::Base.connection_pool.switch_database(conn)
19
+ end
18
20
  conn
19
21
  end
20
22
  end
21
23
 
22
24
  module Migrator
23
- # significant change: just return MIGRATOR_SALT directly
24
- # especially if you're going through pgbouncer, the database
25
- # name you're accessing may not be consistent. it is NOT allowed
26
- # to run migrations against multiple shards in the same database
27
- # concurrently
25
+ # significant change: use the shard name instead of the database name
26
+ # in the lock id. Especially if you're going through pgbouncer, the
27
+ # database name you're accessing may not be consistent
28
28
  def generate_migrator_advisory_lock_id
29
- ::ActiveRecord::Migrator::MIGRATOR_SALT
29
+ db_name_hash = Zlib.crc32(Shard.current.name)
30
+ shard_name_hash = ::ActiveRecord::Migrator::MIGRATOR_SALT * db_name_hash
31
+ # Store in internalmetadata to allow other tools to be able to lock out migrations
32
+ @internal_metadata[:migrator_advisory_lock_id] = shard_name_hash.to_s
33
+ shard_name_hash
30
34
  end
31
35
 
32
- # significant change: strip out prefer_secondary from config
33
- def with_advisory_lock_connection
34
- pool = ::ActiveRecord::ConnectionAdapters::ConnectionHandler.new.establish_connection(
35
- ::ActiveRecord::Base.connection_db_config.configuration_hash.except(:prefer_secondary)
36
- )
37
-
38
- pool.with_connection { |connection| yield(connection) } # rubocop:disable Style/ExplicitBlockArgument
39
- ensure
40
- pool&.disconnect!
36
+ def use_advisory_lock?
37
+ super && pending_migrations.any?
41
38
  end
42
39
  end
43
40
 
44
41
  module MigrationContext
42
+ def migrate(...)
43
+ schema_cache_holder = ::ActiveRecord::Base.connection_pool
44
+ schema_cache_holder = schema_cache_holder.schema_reflection
45
+ previous_schema_cache = schema_cache_holder.instance_variable_get(:@cache)
46
+
47
+ schema_cache_holder.instance_variable_get(:@cache)
48
+
49
+ reset_column_information
50
+ schema_cache_holder.clear!
51
+
52
+ begin
53
+ super
54
+ ensure
55
+ if ::Rails.version < "7.2"
56
+ schema_cache_holder.set_schema_cache(previous_schema_cache)
57
+ else
58
+ schema_cache_holder.instance_variable_set(:@cache, previous_schema_cache)
59
+ end
60
+ reset_column_information
61
+ end
62
+ end
63
+
45
64
  def migrations
46
65
  return @migrations if instance_variable_defined?(:@migrations)
47
66
 
48
67
  migrations_cache = Thread.current[:migrations_cache] ||= {}
49
- key = Digest::MD5.hexdigest(migration_files.sort.join(','))
68
+ key = Digest::MD5.hexdigest(migration_files.sort.join(","))
50
69
  @migrations = migrations_cache[key] ||= super
51
70
  end
71
+
72
+ private
73
+
74
+ def reset_column_information
75
+ ::ActiveRecord::Base.descendants.reject { |m| m <= UnshardedRecord }.each(&:reset_column_information)
76
+ end
52
77
  end
53
78
  end
54
79
  end
@@ -6,7 +6,7 @@ module Switchman
6
6
  module ClassMethods
7
7
  def quoted_table_name
8
8
  @quoted_table_name ||= {}
9
- @quoted_table_name[Shard.current(connection_classes).id] ||= connection.quote_table_name(table_name)
9
+ @quoted_table_name[Shard.current(connection_class_for_self).id] ||= connection.quote_table_name(table_name)
10
10
  end
11
11
  end
12
12
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Switchman
4
+ module ActiveRecord
5
+ module PendingMigrationConnection
6
+ module ClassMethods
7
+ def current_role
8
+ ::ActiveRecord::Base.current_role
9
+ end
10
+
11
+ def current_switchman_shard
12
+ ::ActiveRecord::Base.current_switchman_shard
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -5,17 +5,47 @@ module Switchman
5
5
  module Persistence
6
6
  # touch reads the id attribute directly, so it's not relative to the current shard
7
7
  def touch(*, **)
8
- shard.activate(self.class.connection_classes) { super }
8
+ writable_shadow_record_warning
9
+ shard.activate(self.class.connection_class_for_self) { super }
9
10
  end
10
11
 
11
12
  def update_columns(*)
12
- shard.activate(self.class.connection_classes) { super }
13
+ writable_shadow_record_warning
14
+ shard.activate(self.class.connection_class_for_self) { super }
13
15
  end
14
16
 
15
17
  def delete
16
18
  db = shard.database_server
17
19
  db.unguard { super }
18
20
  end
21
+
22
+ def destroy
23
+ writable_shadow_record_warning
24
+ super
25
+ end
26
+
27
+ def create_or_update(**, &)
28
+ writable_shadow_record_warning
29
+ super
30
+ end
31
+
32
+ def reload(*)
33
+ res = super
34
+ # When a shadow record is reloaded the real record is returned. So
35
+ # we need to ensure the loaded_from_shard is set correctly after a reload.
36
+ @loaded_from_shard = @shard
37
+ if @readonly_from_shadow
38
+ @readonly_from_shadow = false
39
+ @readonly = false
40
+ end
41
+ res
42
+ end
43
+
44
+ def writable_shadow_record_warning
45
+ return unless shadow_record? && Switchman.config[:writable_shadow_records]
46
+
47
+ Switchman::Deprecation.warn("writing to shadow records is not supported")
48
+ end
19
49
  end
20
50
  end
21
51
  end
@@ -5,9 +5,9 @@ module Switchman
5
5
  module PostgreSQLAdapter
6
6
  # copy/paste; use quote_local_table_name
7
7
  def create_database(name, options = {})
8
- options = { encoding: 'utf8' }.merge!(options.symbolize_keys)
8
+ options = { encoding: "utf8" }.merge!(options.symbolize_keys)
9
9
 
10
- option_string = options.sum do |key, value|
10
+ option_string = options.sum("") do |key, value|
11
11
  case key
12
12
  when :owner
13
13
  " OWNER = \"#{value}\""
@@ -24,7 +24,7 @@ module Switchman
24
24
  when :connection_limit
25
25
  " CONNECTION LIMIT = #{value}"
26
26
  else
27
- ''
27
+ ""
28
28
  end
29
29
  end
30
30
 
@@ -37,7 +37,7 @@ module Switchman
37
37
  end
38
38
 
39
39
  def current_schemas
40
- select_values('SELECT * FROM unnest(current_schemas(false))')
40
+ select_values("SELECT * FROM unnest(current_schemas(false))")
41
41
  end
42
42
 
43
43
  def extract_schema_qualified_name(string)
@@ -49,13 +49,13 @@ module Switchman
49
49
  # significant change: use the shard name if no explicit schema
50
50
  def quoted_scope(name = nil, type: nil)
51
51
  schema, name = extract_schema_qualified_name(name)
52
- type = \
52
+ type =
53
53
  case type # rubocop:disable Style/HashLikeCase
54
- when 'BASE TABLE'
54
+ when "BASE TABLE"
55
55
  "'r','p'"
56
- when 'VIEW'
56
+ when "VIEW"
57
57
  "'v','m'"
58
- when 'FOREIGN TABLE'
58
+ when "FOREIGN TABLE"
59
59
  "'f'"
60
60
  end
61
61
  scope = {}
@@ -67,28 +67,43 @@ module Switchman
67
67
 
68
68
  def foreign_keys(table_name)
69
69
  super.each do |fk|
70
- to_table_qualified_name = ::ActiveRecord::ConnectionAdapters::PostgreSQL::Utils.extract_schema_qualified_name(fk.to_table)
70
+ to_table_qualified_name =
71
+ ::ActiveRecord::ConnectionAdapters::PostgreSQL::Utils.extract_schema_qualified_name(fk.to_table)
71
72
  fk.to_table = to_table_qualified_name.identifier if to_table_qualified_name.schema == shard.name
72
73
  end
73
74
  end
74
75
 
76
+ module ClassMethods
77
+ def quote_local_table_name(name)
78
+ # postgres quotes tables and columns the same; just pass through
79
+ # (differs from quote_table_name_with_shard below by no logic to
80
+ # explicitly qualify the table)
81
+ quote_column_name(name)
82
+ end
83
+
84
+ def quote_table_name(name, shard: nil)
85
+ # This looks kind of weird at first glance, but older Rails versions do not actually import
86
+ # these methods as class methods.
87
+ shard = self.shard if shard.nil? && ::Rails.version < "7.2" && !@use_local_table_name
88
+
89
+ return quote_local_table_name(name) unless shard
90
+
91
+ name = ::ActiveRecord::ConnectionAdapters::PostgreSQL::Utils.extract_schema_qualified_name(name.to_s)
92
+ name.instance_variable_set(:@schema, shard.name) unless name.schema
93
+ name.quoted
94
+ end
95
+ end
96
+
75
97
  def quote_local_table_name(name)
76
- # postgres quotes tables and columns the same; just pass through
77
- # (differs from quote_table_name_with_shard below by no logic to
78
- # explicitly qualify the table)
79
- quote_column_name(name)
98
+ self.class.quote_local_table_name(name)
80
99
  end
81
100
 
82
101
  def quote_table_name(name)
83
- return quote_local_table_name(name) if @use_local_table_name
84
-
85
- name = ::ActiveRecord::ConnectionAdapters::PostgreSQL::Utils.extract_schema_qualified_name(name.to_s)
86
- name.instance_variable_set(:@schema, shard.name) unless name.schema
87
- name.quoted
102
+ self.class.quote_table_name(name, shard: @use_local_table_name ? nil : shard)
88
103
  end
89
104
 
90
- def with_global_table_name(&block)
91
- with_local_table_name(false, &block)
105
+ def with_global_table_name(&)
106
+ with_local_table_name(false, &)
92
107
  end
93
108
 
94
109
  def with_local_table_name(enable = true) # rubocop:disable Style/OptionalBooleanParameter
@@ -101,7 +116,7 @@ module Switchman
101
116
 
102
117
  def add_index_options(_table_name, _column_name, **)
103
118
  index, algorithm, if_not_exists = super
104
- algorithm = nil if DatabaseServer.creating_new_shard && algorithm == 'CONCURRENTLY'
119
+ algorithm = nil if DatabaseServer.creating_new_shard && algorithm == "CONCURRENTLY"
105
120
  [index, algorithm, if_not_exists]
106
121
  end
107
122
 
@@ -128,7 +143,7 @@ module Switchman
128
143
  end
129
144
 
130
145
  def columns(*)
131
- with_local_table_name(false) { super }
146
+ with_global_table_name { super }
132
147
  end
133
148
  end
134
149
  end
@@ -11,11 +11,11 @@ module Switchman
11
11
  end
12
12
  end
13
13
 
14
- module AssociationQueryValue
14
+ module PolymorphicArrayValue
15
15
  def convert_to_id(value)
16
16
  case value
17
17
  when ::ActiveRecord::Base
18
- value.id
18
+ value.send(primary_key(value))
19
19
  else
20
20
  super
21
21
  end
@@ -8,27 +8,36 @@ module Switchman
8
8
  def cache_sql(sql, name, binds)
9
9
  # have to include the shard id in the cache key because of switching dbs on the same connection
10
10
  sql = "#{shard.id}::#{sql}"
11
+ key = binds.empty? ? sql : [sql, binds]
12
+ result = nil
13
+ hit = false
14
+
11
15
  @lock.synchronize do
12
- result =
13
- if query_cache[sql].key?(binds)
14
- args = {
15
- sql: sql,
16
- binds: binds,
17
- name: name,
18
- connection_id: object_id,
19
- cached: true,
20
- type_casted_binds: -> { type_casted_binds(binds) }
21
- }
22
- ::ActiveSupport::Notifications.instrument(
23
- 'sql.active_record',
24
- args
25
- )
26
- query_cache[sql][binds]
16
+ if ::Rails.version < "7.2"
17
+ if (result = @query_cache.delete(key))
18
+ hit = true
19
+ @query_cache[key] = result
27
20
  else
28
- query_cache[sql][binds] = yield
21
+ result = @query_cache[key] = yield
22
+ @query_cache.shift if @query_cache_max_size && @query_cache.size > @query_cache_max_size
23
+ end
24
+ else
25
+ hit = true
26
+ result = @query_cache.compute_if_absent(key) do
27
+ hit = false
28
+ yield
29
29
  end
30
- result.dup
30
+ end
31
31
  end
32
+
33
+ if hit
34
+ ::ActiveSupport::Notifications.instrument(
35
+ "sql.active_record",
36
+ cache_notification_info(sql, name, binds)
37
+ )
38
+ end
39
+
40
+ result.dup
32
41
  end
33
42
  end
34
43
  end