rails_failover 0.2.0 → 0.5.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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