switchman 3.3.1 → 4.0.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +15 -14
  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 +4 -2
  6. data/lib/switchman/active_record/associations.rb +89 -49
  7. data/lib/switchman/active_record/attribute_methods.rb +72 -34
  8. data/lib/switchman/active_record/base.rb +145 -27
  9. data/lib/switchman/active_record/calculations.rb +96 -49
  10. data/lib/switchman/active_record/connection_handler.rb +18 -0
  11. data/lib/switchman/active_record/connection_pool.rb +24 -3
  12. data/lib/switchman/active_record/database_configurations.rb +37 -15
  13. data/lib/switchman/active_record/finder_methods.rb +44 -14
  14. data/lib/switchman/active_record/log_subscriber.rb +11 -5
  15. data/lib/switchman/active_record/migration.rb +45 -3
  16. data/lib/switchman/active_record/pending_migration_connection.rb +17 -0
  17. data/lib/switchman/active_record/persistence.rb +30 -0
  18. data/lib/switchman/active_record/postgresql_adapter.rb +11 -10
  19. data/lib/switchman/active_record/predicate_builder.rb +2 -2
  20. data/lib/switchman/active_record/query_cache.rb +49 -20
  21. data/lib/switchman/active_record/query_methods.rb +93 -30
  22. data/lib/switchman/active_record/relation.rb +23 -12
  23. data/lib/switchman/active_record/spawn_methods.rb +2 -2
  24. data/lib/switchman/active_record/statement_cache.rb +2 -2
  25. data/lib/switchman/active_record/tasks/database_tasks.rb +6 -1
  26. data/lib/switchman/active_record/test_fixtures.rb +26 -16
  27. data/lib/switchman/active_support/cache.rb +9 -4
  28. data/lib/switchman/arel.rb +34 -18
  29. data/lib/switchman/call_super.rb +2 -8
  30. data/lib/switchman/database_server.rb +69 -31
  31. data/lib/switchman/default_shard.rb +14 -3
  32. data/lib/switchman/engine.rb +29 -22
  33. data/lib/switchman/environment.rb +2 -2
  34. data/lib/switchman/errors.rb +13 -0
  35. data/lib/switchman/guard_rail/relation.rb +1 -2
  36. data/lib/switchman/parallel.rb +6 -6
  37. data/lib/switchman/r_spec_helper.rb +12 -11
  38. data/lib/switchman/shard.rb +180 -68
  39. data/lib/switchman/sharded_instrumenter.rb +3 -3
  40. data/lib/switchman/shared_schema_cache.rb +11 -0
  41. data/lib/switchman/standard_error.rb +4 -0
  42. data/lib/switchman/test_helper.rb +2 -2
  43. data/lib/switchman/version.rb +1 -1
  44. data/lib/switchman.rb +27 -15
  45. data/lib/tasks/switchman.rake +96 -60
  46. metadata +38 -48
@@ -7,14 +7,26 @@ 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, **)
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') }
10
+ if ::Rails.version < "7.1"
11
+ def configs_for(include_replicas: false, name: nil, **)
12
+ res = super
13
+ if name && !include_replicas
14
+ return nil unless name.end_with?("primary")
15
+ elsif !include_replicas
16
+ return res.select { |config| config.name.end_with?("primary") }
17
+ end
18
+ res
19
+ end
20
+ else
21
+ def configs_for(include_hidden: false, name: nil, **)
22
+ res = super
23
+ if name && !include_hidden
24
+ return nil unless name.end_with?("primary")
25
+ elsif !include_hidden
26
+ return res.select { |config| config.name.end_with?("primary") }
27
+ end
28
+ res
16
29
  end
17
- res
18
30
  end
19
31
 
20
32
  private
@@ -27,21 +39,31 @@ module Switchman
27
39
  return configs if configs.is_a?(Array)
28
40
 
29
41
  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)
42
+ if config.is_a?(Hash)
43
+ # It would be nice to do the auto-fallback that we want here, but we haven't
44
+ # actually done that for years (or maybe ever) and it will be a big lift to get working
45
+ roles = config.keys.select do |k|
46
+ config[k].is_a?(Hash) || (config[k].is_a?(Array) && config[k].all?(Hash))
47
+ end
48
+ base_config = config.except(*roles)
49
+ else
50
+ base_config = config
51
+ roles = []
52
+ end
34
53
 
35
54
  name = "#{env_name}/primary"
36
- name = 'primary' if env_name == default_env
55
+ name = "primary" if env_name == default_env
37
56
  base_db = build_db_config_from_raw_config(env_name, name, base_config)
38
57
  [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]))
58
+ build_db_config_from_raw_config(
59
+ env_name,
60
+ "#{env_name}/#{role}",
61
+ base_config.merge(config[role].is_a?(Array) ? config[role].first : config[role])
62
+ )
41
63
  end
42
64
  end
43
65
 
44
- db_configs << environment_url_config(default_env, 'primary', {}) unless db_configs.find(&:for_current_env?)
66
+ db_configs << environment_url_config(default_env, "primary", {}) unless db_configs.find(&:for_current_env?)
45
67
 
46
68
  merge_db_environment_variables(default_env, db_configs.compact)
47
69
  end
@@ -42,26 +42,56 @@ module Switchman
42
42
  primary_shard.activate { super }
43
43
  end
44
44
 
45
- def exists?(conditions = :none)
46
- conditions = conditions.id if ::ActiveRecord::Base === conditions
47
- return false unless conditions
45
+ if ::Rails.version < "7.1"
46
+ def exists?(conditions = :none)
47
+ conditions = conditions.id if ::ActiveRecord::Base === conditions
48
+ return false unless conditions
48
49
 
49
- relation = apply_join_dependency(eager_loading: false)
50
- return false if ::ActiveRecord::NullRelation === relation
50
+ relation = apply_join_dependency(eager_loading: false)
51
+ return false if ::ActiveRecord::NullRelation === relation
51
52
 
52
- relation = relation.except(:select, :order).select('1 AS one').limit(1)
53
+ relation = relation.except(:select, :order).select("1 AS one").limit(1)
53
54
 
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
55
+ case conditions
56
+ when Array, Hash
57
+ relation = relation.where(conditions)
58
+ else
59
+ relation = relation.where(table[primary_key].eq(conditions)) if conditions != :none
60
+ end
61
+
62
+ relation.activate do |shard_rel|
63
+ return true if connection.select_value(shard_rel.arel, "#{name} Exists")
64
+ end
65
+ false
59
66
  end
67
+ else
68
+ def exists?(conditions = :none)
69
+ return false if @none
70
+
71
+ if Base === conditions
72
+ raise ArgumentError, <<-TEXT.squish
73
+ You are passing an instance of ActiveRecord::Base to `exists?`.
74
+ Please pass the id of the object by calling `.id`.
75
+ TEXT
76
+ end
60
77
 
61
- relation.activate do |shard_rel|
62
- return true if connection.select_value(shard_rel.arel, "#{name} Exists")
78
+ return false if !conditions || limit_value == 0 # rubocop:disable Style/NumericPredicate
79
+
80
+ if eager_loading?
81
+ relation = apply_join_dependency(eager_loading: false)
82
+ return relation.exists?(conditions)
83
+ end
84
+
85
+ relation = construct_relation_for_exists(conditions)
86
+ return false if relation.where_clause.contradiction?
87
+
88
+ relation.activate do |shard_rel|
89
+ return true if skip_query_cache_if_necessary do
90
+ connection.select_rows(shard_rel.arel, "#{name} Exists?").size == 1
91
+ end
92
+ end
93
+ false
63
94
  end
64
- false
65
95
  end
66
96
  end
67
97
  end
@@ -5,8 +5,10 @@ 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?
8
+ if ::Rails.version < "7.1"
9
+ self.class.runtime += event.duration
10
+ return unless logger.debug?
11
+ end
10
12
 
11
13
  payload = event.payload
12
14
 
@@ -14,20 +16,24 @@ module Switchman
14
16
 
15
17
  name = "#{payload[:name]} (#{event.duration.round(1)}ms)"
16
18
  name = "CACHE #{name}" if payload[:cached]
17
- sql = payload[:sql].squeeze(' ')
19
+ sql = payload[:sql].squeeze(" ")
18
20
  binds = nil
19
21
  shard = payload[:shard]
20
22
  shard = " [#{shard[:database_server_id]}:#{shard[:id]} #{shard[:env]}]" if shard
21
23
 
22
24
  unless (payload[:binds] || []).empty?
23
25
  casted_params = type_casted_binds(payload[:type_casted_binds])
24
- binds = ' ' + payload[:binds].zip(casted_params).map do |attr, value|
26
+ binds = " " + payload[:binds].zip(casted_params).map do |attr, value|
25
27
  render_bind(attr, value)
26
28
  end.inspect
27
29
  end
28
30
 
29
31
  name = colorize_payload_name(name, payload[:name])
30
- sql = color(sql, sql_color(sql), true)
32
+ sql = if ::Rails.version < "7.1"
33
+ color(sql, sql_color(sql), true)
34
+ else
35
+ color(sql, sql_color(sql), bold: true)
36
+ end
31
37
 
32
38
  debug " #{name} #{sql}#{binds}#{shard}"
33
39
  end
@@ -14,7 +14,9 @@ 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
@@ -27,7 +29,11 @@ module Switchman
27
29
  db_name_hash = Zlib.crc32(Shard.current.name)
28
30
  shard_name_hash = ::ActiveRecord::Migrator::MIGRATOR_SALT * db_name_hash
29
31
  # Store in internalmetadata to allow other tools to be able to lock out migrations
30
- ::ActiveRecord::InternalMetadata[:migrator_advisory_lock_id] = shard_name_hash
32
+ if ::Rails.version < "7.1"
33
+ ::ActiveRecord::InternalMetadata[:migrator_advisory_lock_id] = shard_name_hash
34
+ else
35
+ ::ActiveRecord::InternalMetadata.new(connection)[:migrator_advisory_lock_id] = shard_name_hash
36
+ end
31
37
  shard_name_hash
32
38
  end
33
39
 
@@ -44,13 +50,49 @@ module Switchman
44
50
  end
45
51
 
46
52
  module MigrationContext
53
+ def migrate(...)
54
+ connection = ::ActiveRecord::Base.connection
55
+ schema_cache_holder = ::ActiveRecord::Base.connection_pool
56
+ schema_cache_holder = schema_cache_holder.schema_reflection if ::Rails.version >= "7.1"
57
+ previous_schema_cache = if ::Rails.version < "7.1"
58
+ schema_cache_holder.get_schema_cache(connection)
59
+ else
60
+ schema_cache_holder.instance_variable_get(:@cache)
61
+ end
62
+
63
+ if ::Rails.version < "7.1"
64
+ temporary_schema_cache = ::ActiveRecord::ConnectionAdapters::SchemaCache.new(connection)
65
+
66
+ reset_column_information
67
+ schema_cache_holder.set_schema_cache(temporary_schema_cache)
68
+ else
69
+ schema_cache_holder.instance_variable_get(:@cache)
70
+
71
+ reset_column_information
72
+ schema_cache_holder.clear!
73
+ end
74
+
75
+ begin
76
+ super(...)
77
+ ensure
78
+ schema_cache_holder.set_schema_cache(previous_schema_cache)
79
+ reset_column_information
80
+ end
81
+ end
82
+
47
83
  def migrations
48
84
  return @migrations if instance_variable_defined?(:@migrations)
49
85
 
50
86
  migrations_cache = Thread.current[:migrations_cache] ||= {}
51
- key = Digest::MD5.hexdigest(migration_files.sort.join(','))
87
+ key = Digest::MD5.hexdigest(migration_files.sort.join(","))
52
88
  @migrations = migrations_cache[key] ||= super
53
89
  end
90
+
91
+ private
92
+
93
+ def reset_column_information
94
+ ::ActiveRecord::Base.descendants.reject { |m| m <= UnshardedRecord }.each(&:reset_column_information)
95
+ end
54
96
  end
55
97
  end
56
98
  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,10 +5,12 @@ 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
+ writable_shadow_record_warning
8
9
  shard.activate(self.class.connection_class_for_self) { super }
9
10
  end
10
11
 
11
12
  def update_columns(*)
13
+ writable_shadow_record_warning
12
14
  shard.activate(self.class.connection_class_for_self) { super }
13
15
  end
14
16
 
@@ -16,6 +18,34 @@ module Switchman
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(**, &block)
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,7 +67,8 @@ 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
@@ -101,7 +102,7 @@ module Switchman
101
102
 
102
103
  def add_index_options(_table_name, _column_name, **)
103
104
  index, algorithm, if_not_exists = super
104
- algorithm = nil if DatabaseServer.creating_new_shard && algorithm == 'CONCURRENTLY'
105
+ algorithm = nil if DatabaseServer.creating_new_shard && algorithm == "CONCURRENTLY"
105
106
  [index, algorithm, if_not_exists]
106
107
  end
107
108
 
@@ -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
@@ -5,28 +5,57 @@ module Switchman
5
5
  module QueryCache
6
6
  private
7
7
 
8
- def cache_sql(sql, name, binds)
9
- # have to include the shard id in the cache key because of switching dbs on the same connection
10
- sql = "#{shard.id}::#{sql}"
11
- @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]
8
+ if ::Rails.version < "7.1"
9
+ def cache_sql(sql, name, binds)
10
+ # have to include the shard id in the cache key because of switching dbs on the same connection
11
+ sql = "#{shard.id}::#{sql}"
12
+ @lock.synchronize do
13
+ result =
14
+ if query_cache[sql].key?(binds)
15
+ args = {
16
+ sql: sql,
17
+ binds: binds,
18
+ name: name,
19
+ connection_id: object_id,
20
+ cached: true,
21
+ type_casted_binds: -> { type_casted_binds(binds) }
22
+ }
23
+ ::ActiveSupport::Notifications.instrument(
24
+ "sql.active_record",
25
+ args
26
+ )
27
+ query_cache[sql][binds]
28
+ else
29
+ query_cache[sql][binds] = yield
30
+ end
31
+ result.dup
32
+ end
33
+ end
34
+ else
35
+ def cache_sql(sql, name, binds)
36
+ # have to include the shard id in the cache key because of switching dbs on the same connection
37
+ sql = "#{shard.id}::#{sql}"
38
+ key = binds.empty? ? sql : [sql, binds]
39
+ result = nil
40
+ hit = false
41
+
42
+ @lock.synchronize do
43
+ if (result = @query_cache.delete(key))
44
+ hit = true
45
+ @query_cache[key] = result
27
46
  else
28
- query_cache[sql][binds] = yield
47
+ result = @query_cache[key] = yield
48
+ @query_cache.shift if @query_cache_max_size && @query_cache.size > @query_cache_max_size
29
49
  end
50
+ end
51
+
52
+ if hit
53
+ ::ActiveSupport::Notifications.instrument(
54
+ "sql.active_record",
55
+ cache_notification_info(sql, name, binds)
56
+ )
57
+ end
58
+
30
59
  result.dup
31
60
  end
32
61
  end