rails_failover 0.2.0 → 0.5.2

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.
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsFailover
4
+ module ActiveRecord
5
+ class Interceptor
6
+ def self.adapter_errors
7
+ @adapter_errors ||= begin
8
+ if defined?(::PG)
9
+ [::PG::ServerError, ::PG::UnableToSend, ::PG::ConnectionBad]
10
+ elsif defined?(::Mysql2)
11
+ [::Mysql2::Error::ConnectionError]
12
+ end
13
+ end
14
+ end
15
+
16
+ def self.handle(request, exception)
17
+ exception = resolve_cause(exception)
18
+
19
+ if adapter_errors.any? { |error| exception.is_a?(error) }
20
+ Handler.instance.verify_primary(request.env[Middleware::WRITING_ROLE_HEADER])
21
+ end
22
+ end
23
+
24
+ def self.resolve_cause(exception)
25
+ if exception.cause
26
+ resolve_cause(exception.cause)
27
+ else
28
+ exception
29
+ end
30
+ end
31
+ end
32
+
33
+ class Middleware
34
+ class << self
35
+ attr_accessor :force_reading_role_callback
36
+ end
37
+
38
+ CURRENT_ROLE_HEADER = "rails_failover.role"
39
+ WRITING_ROLE_HEADER = "rails_failover.writing_role"
40
+
41
+ def initialize(app)
42
+ @app = app
43
+ end
44
+
45
+ def call(env)
46
+ current_role = ::ActiveRecord::Base.current_role || ::ActiveRecord::Base.writing_role
47
+ is_writing_role = current_role.to_s.end_with?(::ActiveRecord::Base.writing_role.to_s)
48
+ writing_role = resolve_writing_role(current_role, is_writing_role)
49
+
50
+ role =
51
+ if primary_down = self.class.force_reading_role_callback&.call(env) || Handler.instance.primary_down?(writing_role)
52
+ reading_role = resolve_reading_role(current_role, is_writing_role)
53
+ ensure_reading_connection_established!(writing_role: writing_role, reading_role: reading_role)
54
+ reading_role
55
+ else
56
+ writing_role
57
+ end
58
+
59
+ ::ActiveRecord::Base.connected_to(role: role) do
60
+ env[CURRENT_ROLE_HEADER] = role
61
+ env[WRITING_ROLE_HEADER] = writing_role
62
+ @app.call(env)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def ensure_reading_connection_established!(writing_role:, reading_role:)
69
+ ::ActiveRecord::Base.connection_handlers[reading_role] ||= begin
70
+ handler = ::ActiveRecord::ConnectionAdapters::ConnectionHandler.new
71
+
72
+ ::ActiveRecord::Base.connection_handlers[writing_role].connection_pools.each do |pool|
73
+ ::RailsFailover::ActiveRecord.establish_reading_connection(handler, pool.spec)
74
+ end
75
+
76
+ handler
77
+ end
78
+ end
79
+
80
+ def resolve_writing_role(current_role, is_writing_role)
81
+ if is_writing_role
82
+ current_role
83
+ else
84
+ current_role.to_s.sub(
85
+ /#{::ActiveRecord::Base.reading_role}$/,
86
+ ::ActiveRecord::Base.writing_role.to_s
87
+ ).to_sym
88
+ end
89
+ end
90
+
91
+ def resolve_reading_role(current_role, is_writing_role)
92
+ if is_writing_role
93
+ current_role.to_s.sub(
94
+ /#{::ActiveRecord::Base.writing_role}$/,
95
+ ::ActiveRecord::Base.reading_role.to_s
96
+ ).to_sym
97
+ else
98
+ current_role
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsFailover
4
+ module ActiveRecord
5
+ class Railtie < ::Rails::Railtie
6
+ initializer "rails_failover.init", after: "active_record.initialize_database" do |app|
7
+ config = ::ActiveRecord::Base.connection_config
8
+ app.config.active_record_rails_failover = false
9
+
10
+ if !!(config[:replica_host] && config[:replica_port])
11
+ app.config.active_record_rails_failover = true
12
+
13
+ ::ActiveSupport.on_load(:active_record) do
14
+ Handler.instance
15
+
16
+ # We are doing this manually for now since we're awaiting Rails 6.1 to be released which will
17
+ # have more stable ActiveRecord APIs for handling multiple databases with different roles.
18
+ ::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.reading_role] =
19
+ ::ActiveRecord::ConnectionAdapters::ConnectionHandler.new
20
+
21
+ ::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.writing_role].connection_pools.each do |connection_pool|
22
+ RailsFailover::ActiveRecord.establish_reading_connection(
23
+ ::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.reading_role],
24
+ connection_pool.spec
25
+ )
26
+ end
27
+
28
+ begin
29
+ ::ActiveRecord::Base.connection
30
+ rescue ::ActiveRecord::NoDatabaseError
31
+ # Do nothing since database hasn't been created
32
+ rescue ::PG::Error => e
33
+ Handler.instance.verify_primary(::ActiveRecord::Base.writing_role)
34
+ ::ActiveRecord::Base.connection_handler = ::ActiveRecord::Base.lookup_connection_handler(:reading)
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ initializer "rails_failover.insert_middleware" do |app|
41
+ if app.config.active_record_rails_failover
42
+ ActionDispatch::DebugExceptions.register_interceptor do |request, exception|
43
+ RailsFailover::ActiveRecord::Interceptor.handle(request, exception)
44
+ end
45
+
46
+ if !skip_middleware?(app.config)
47
+ app.middleware.unshift(::RailsFailover::ActiveRecord::Middleware)
48
+ end
49
+ end
50
+ end
51
+
52
+ def skip_middleware?(config)
53
+ return false if !config.respond_to?(:skip_rails_failover_active_record_middleware)
54
+ config.skip_rails_failover_active_record_middleware
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,5 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'redis'
4
+
5
+ supported_version = '4'
6
+
7
+ if Gem::Version.new(Redis::VERSION) < Gem::Version.new(supported_version)
8
+ raise "redis #{Redis::VERSION} is not supported. Please upgrade to Redis #{supported_version}."
9
+ end
10
+
11
+ require_relative "../redis/patches/client"
3
12
  require_relative 'redis/connector'
4
13
 
5
14
  module RailsFailover
@@ -9,33 +18,41 @@ module RailsFailover
9
18
  end
10
19
 
11
20
  def self.logger
12
- @logger
21
+ if @logger
22
+ @logger
23
+ elsif defined?(::Rails)
24
+ ::Rails.logger
25
+ end
26
+ end
27
+
28
+ def self.verify_primary_frequency_seconds=(seconds)
29
+ @verify_primary_frequency_seconds = seconds
13
30
  end
14
31
 
15
- def self.verify_master_frequency_seconds=(seconds)
16
- @verify_master_frequency_seconds = seconds
32
+ def self.verify_primary_frequency_seconds
33
+ @verify_primary_frequency_seconds || 5
17
34
  end
18
35
 
19
- def self.verify_master_frequency_seconds
20
- @verify_master_frequency_seconds || 5
36
+ def self.on_failover(&block)
37
+ @on_failover_callback = block
21
38
  end
22
39
 
23
- def self.register_master_down_callback(&block)
24
- @master_down_callbacks ||= []
25
- @master_down_callbacks.push(block)
40
+ def self.on_failover_callback
41
+ @on_failover_callback
26
42
  end
27
43
 
28
- def self.master_down_callbacks
29
- @master_down_callbacks || []
44
+ def self.on_fallback(&block)
45
+ @on_fallback_callback = block
30
46
  end
31
47
 
32
- def self.register_master_up_callback(&block)
33
- @master_up_callbacks ||= []
34
- @master_up_callbacks.push(block)
48
+ def self.on_fallback_callback
49
+ @on_fallback_callback
35
50
  end
36
51
 
37
- def self.master_up_callbacks
38
- @master_up_callbacks || []
52
+ # For testing
53
+ def self.clear_callbacks
54
+ @on_fallback_callback = nil
55
+ @on_failover_callback = nil
39
56
  end
40
57
  end
41
58
  end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'failover_handler'
3
+ require_relative 'handler'
4
4
 
5
5
  module RailsFailover
6
6
  class Redis
7
- class Connector
7
+ class Connector < ::Redis::Client::Connector
8
8
  def initialize(options)
9
- options[:original_driver] = options[:driver]
9
+ orignal_driver = options[:driver]
10
10
 
11
11
  options[:driver] = Class.new(options[:driver]) do
12
12
  def self.connect(options)
@@ -22,26 +22,32 @@ module RailsFailover
22
22
  Errno::ETIMEDOUT,
23
23
  Errno::EINVAL => e
24
24
 
25
- FailoverHandler.instance.verify_master(options.dup)
25
+ Handler.instance.verify_primary(options)
26
26
  raise e
27
27
  end
28
28
  end
29
29
 
30
+ options[:original_driver] = orignal_driver
30
31
  options.delete(:connector)
31
- @options = options.dup
32
+ options[:id] ||= "#{options[:host]}:#{options[:port]}"
32
33
  @replica_options = replica_options(options)
34
+ @options = options.dup
33
35
  end
34
36
 
35
37
  def resolve
36
- FailoverHandler.instance.master ? @options : @replica_options
38
+ if Handler.instance.primary_down?(@options)
39
+ @replica_options
40
+ else
41
+ @options
42
+ end
37
43
  end
38
44
 
39
45
  def check(client)
40
- FailoverHandler.instance.register_client(client)
46
+ Handler.instance.register_client(client)
41
47
  end
42
48
 
43
49
  def on_disconnect(client)
44
- FailoverHandler.instance.deregister_client(client)
50
+ Handler.instance.deregister_client(client)
45
51
  end
46
52
 
47
53
  private
@@ -50,7 +56,6 @@ module RailsFailover
50
56
  opts = options.dup
51
57
  opts[:host] = opts.delete(:replica_host)
52
58
  opts[:port] = opts.delete(:replica_port)
53
- opts[:driver] = opts.delete(:original_driver)
54
59
  opts
55
60
  end
56
61
  end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+ require 'singleton'
5
+
6
+ module RailsFailover
7
+ class Redis
8
+ class Handler
9
+ include Singleton
10
+ include MonitorMixin
11
+
12
+ PRIMARY_ROLE_STATUS = "role:master"
13
+ PRIMARY_LOADED_STATUS = "loading:0"
14
+ VERIFY_FREQUENCY_BUFFER_PRECENT = 20
15
+
16
+ def initialize
17
+ @primaries_down = {}
18
+ @clients = {}
19
+ @ancestor_pid = Process.pid
20
+
21
+ super() # Monitor#initialize
22
+ end
23
+
24
+ def verify_primary(options)
25
+ mon_synchronize do
26
+ primary_down(options)
27
+ disconnect_clients(options)
28
+
29
+ return if @thread&.alive?
30
+
31
+ logger&.warn "Failover for Redis has been initiated"
32
+
33
+ begin
34
+ RailsFailover::Redis.on_failover_callback&.call
35
+ rescue => e
36
+ logger&.warn("RailsFailover::Redis.on_failover_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
37
+ end
38
+
39
+ @thread = Thread.new do
40
+ loop do
41
+ thread = Thread.new { initiate_fallback_to_primary }
42
+ thread.join
43
+
44
+ if all_primaries_up
45
+ logger&.warn "Fallback to primary for Redis has been completed."
46
+
47
+ begin
48
+ RailsFailover::Redis.on_fallback_callback&.call
49
+ rescue => e
50
+ logger&.warn("RailsFailover::Redis.on_fallback_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
51
+ end
52
+
53
+ break
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ def initiate_fallback_to_primary
61
+ frequency = RailsFailover::Redis.verify_primary_frequency_seconds
62
+ sleep(frequency * ((rand(VERIFY_FREQUENCY_BUFFER_PRECENT) + 100) / 100.0))
63
+
64
+ active_primaries_keys = {}
65
+
66
+ primaries_down.each do |key, options|
67
+ info = nil
68
+ options = options.dup
69
+
70
+ begin
71
+ options[:driver] = options[:original_driver]
72
+ primary_client = ::Redis::Client.new(options)
73
+ logger&.debug "Checking connection to primary server (#{key})"
74
+ info = primary_client.call([:info])
75
+ rescue => e
76
+ logger&.debug "Connection to primary server (#{key}) failed with '#{e.message}'"
77
+ ensure
78
+ primary_client&.disconnect
79
+ end
80
+
81
+ if info && info.include?(PRIMARY_LOADED_STATUS) && info.include?(PRIMARY_ROLE_STATUS)
82
+ active_primaries_keys[key] = options
83
+ logger&.debug "Primary server (#{key}) is active, disconnecting clients from replica"
84
+ end
85
+ end
86
+
87
+ active_primaries_keys.each do |key, options|
88
+ primary_up(options)
89
+ disconnect_clients(options)
90
+ end
91
+ end
92
+
93
+ def register_client(client)
94
+ key = client.options[:id]
95
+
96
+ mon_synchronize do
97
+ clients[key] ||= []
98
+ clients[key] << client
99
+ end
100
+ end
101
+
102
+ def deregister_client(client)
103
+ key = client.options[:id]
104
+
105
+ mon_synchronize do
106
+ if clients[key]
107
+ clients[key].delete(client)
108
+
109
+ if clients[key].empty?
110
+ clients.delete(key)
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ def primary_down?(options)
117
+ mon_synchronize do
118
+ primaries_down[options[:id]]
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def all_primaries_up
125
+ mon_synchronize { primaries_down.empty? }
126
+ end
127
+
128
+ def primary_up(options)
129
+ mon_synchronize do
130
+ primaries_down.delete(options[:id])
131
+ end
132
+ end
133
+
134
+ def primary_down(options)
135
+ mon_synchronize do
136
+ primaries_down[options[:id]] = options.dup
137
+ end
138
+ end
139
+
140
+ def clients
141
+ process_pid = Process.pid
142
+ return @clients[process_pid] if @clients[process_pid]
143
+
144
+ mon_synchronize do
145
+ if !@clients[process_pid]
146
+ @clients[process_pid] = {}
147
+
148
+ if process_pid != @ancestor_pid
149
+ @clients.delete(@ancestor_pid)
150
+ end
151
+ end
152
+
153
+ @clients[process_pid]
154
+ end
155
+ end
156
+
157
+ def primaries_down
158
+ process_pid = Process.pid
159
+ return @primaries_down[process_pid] if @primaries_down[process_pid]
160
+
161
+ mon_synchronize do
162
+ if !@primaries_down[process_pid]
163
+ @primaries_down[process_pid] = @primaries_down[@ancestor_pid] || {}
164
+
165
+ if process_pid != @ancestor_pid
166
+ @primaries_down.delete(@ancestor_pid).each do |id, options|
167
+ verify_primary(options)
168
+ end
169
+ end
170
+ end
171
+
172
+ @primaries_down[process_pid]
173
+ end
174
+ end
175
+
176
+ def disconnect_clients(options)
177
+ key = options[:id]
178
+
179
+ mon_synchronize do
180
+ if clients[key]
181
+ clients[key].dup.each do |c|
182
+ c.disconnect
183
+ end
184
+ end
185
+ end
186
+ end
187
+
188
+ def logger
189
+ RailsFailover::Redis.logger
190
+ end
191
+ end
192
+ end
193
+ end