rails_failover 0.1.0 → 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsFailover
4
+ module ActiveRecord
5
+ class Interceptor
6
+ def self.adapter_error
7
+ @adapter_error ||= begin
8
+ if defined?(::PG)
9
+ ::PG::Error
10
+ elsif defined?(::Mysql2)
11
+ ::Mysql2::Error
12
+ end
13
+ end
14
+ end
15
+
16
+ def self.handle(request, exception)
17
+ if (resolve_cause(exception).is_a?(adapter_error))
18
+ Handler.instance.verify_primary(request.env[Middleware::WRITING_ROLE_HEADER])
19
+ end
20
+ end
21
+
22
+ def self.resolve_cause(exception)
23
+ if exception.cause
24
+ resolve_cause(exception.cause)
25
+ else
26
+ exception
27
+ end
28
+ end
29
+ end
30
+
31
+ class Middleware
32
+ class << self
33
+ attr_accessor :force_reading_role_callback
34
+ end
35
+
36
+ CURRENT_ROLE_HEADER = "rails_failover.role"
37
+ WRITING_ROLE_HEADER = "rails_failover.writing_role"
38
+
39
+ def initialize(app)
40
+ @app = app
41
+ end
42
+
43
+ def call(env)
44
+ current_role = ::ActiveRecord::Base.current_role || ::ActiveRecord::Base.writing_role
45
+ is_writing_role = current_role.to_s.end_with?(::ActiveRecord::Base.writing_role.to_s)
46
+ writing_role = resolve_writing_role(current_role, is_writing_role)
47
+
48
+ role =
49
+ if primary_down = self.class.force_reading_role_callback&.call(env) || Handler.instance.primary_down?(writing_role)
50
+ reading_role = resolve_reading_role(current_role, is_writing_role)
51
+ ensure_reading_connection_established!(writing_role: writing_role, reading_role: reading_role)
52
+ reading_role
53
+ else
54
+ writing_role
55
+ end
56
+
57
+ ::ActiveRecord::Base.connected_to(role: role) do
58
+ env[CURRENT_ROLE_HEADER] = role
59
+ env[WRITING_ROLE_HEADER] = writing_role
60
+ @app.call(env)
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def ensure_reading_connection_established!(writing_role:, reading_role:)
67
+ ::ActiveRecord::Base.connection_handlers[reading_role] ||= begin
68
+ handler = ::ActiveRecord::ConnectionAdapters::ConnectionHandler.new
69
+
70
+ ::ActiveRecord::Base.connection_handlers[writing_role].connection_pools.each do |pool|
71
+ ::RailsFailover::ActiveRecord.establish_reading_connection(handler, pool.spec)
72
+ end
73
+
74
+ handler
75
+ end
76
+ end
77
+
78
+ def resolve_writing_role(current_role, is_writing_role)
79
+ if is_writing_role
80
+ current_role
81
+ else
82
+ current_role.to_s.sub(
83
+ /#{::ActiveRecord::Base.reading_role}$/,
84
+ ::ActiveRecord::Base.writing_role.to_s
85
+ ).to_sym
86
+ end
87
+ end
88
+
89
+ def resolve_reading_role(current_role, is_writing_role)
90
+ if is_writing_role
91
+ current_role.to_s.sub(
92
+ /#{::ActiveRecord::Base.writing_role}$/,
93
+ ::ActiveRecord::Base.reading_role.to_s
94
+ ).to_sym
95
+ else
96
+ current_role
97
+ end
98
+ end
99
+ end
100
+ end
101
+ 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,3 +1,14 @@
1
+ # frozen_string_literal: true
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"
1
12
  require_relative 'redis/connector'
2
13
 
3
14
  module RailsFailover
@@ -7,33 +18,41 @@ module RailsFailover
7
18
  end
8
19
 
9
20
  def self.logger
10
- @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
11
30
  end
12
31
 
13
- def self.verify_master_frequency_seconds=(seconds)
14
- @verify_master_frequency_seconds = seconds
32
+ def self.verify_primary_frequency_seconds
33
+ @verify_primary_frequency_seconds || 5
15
34
  end
16
35
 
17
- def self.verify_master_frequency_seconds
18
- @verify_master_frequency_seconds || 5
36
+ def self.on_failover(&block)
37
+ @on_failover_callback = block
19
38
  end
20
39
 
21
- def self.register_master_down_callback(&block)
22
- @master_down_callbacks ||= []
23
- @master_down_callbacks.push(block)
40
+ def self.on_failover_callback
41
+ @on_failover_callback
24
42
  end
25
43
 
26
- def self.master_down_callbacks
27
- @master_down_callbacks || []
44
+ def self.on_fallback(&block)
45
+ @on_fallback_callback = block
28
46
  end
29
47
 
30
- def self.register_master_up_callback(&block)
31
- @master_up_callbacks ||= []
32
- @master_up_callbacks.push(block)
48
+ def self.on_fallback_callback
49
+ @on_fallback_callback
33
50
  end
34
51
 
35
- def self.master_up_callbacks
36
- @master_up_callbacks || []
52
+ # For testing
53
+ def self.clear_callbacks
54
+ @on_fallback_callback = nil
55
+ @on_failover_callback = nil
37
56
  end
38
57
  end
39
58
  end
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
- require_relative 'fallback_handler'
2
+
3
+ require_relative 'handler'
3
4
 
4
5
  module RailsFailover
5
6
  class Redis
6
- class Connector
7
+ class Connector < ::Redis::Client::Connector
7
8
  def initialize(options)
8
- options[:original_driver] = options[:driver]
9
+ orignal_driver = options[:driver]
9
10
 
10
11
  options[:driver] = Class.new(options[:driver]) do
11
12
  def self.connect(options)
@@ -21,23 +22,32 @@ module RailsFailover
21
22
  Errno::ETIMEDOUT,
22
23
  Errno::EINVAL => e
23
24
 
24
- FallbackHandler.instance.master = false
25
- FallbackHandler.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
- FallbackHandler.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
- FallbackHandler.instance.register_client(client)
46
+ Handler.instance.register_client(client)
47
+ end
48
+
49
+ def on_disconnect(client)
50
+ Handler.instance.deregister_client(client)
41
51
  end
42
52
 
43
53
  private
@@ -46,7 +56,6 @@ module RailsFailover
46
56
  opts = options.dup
47
57
  opts[:host] = opts.delete(:replica_host)
48
58
  opts[:port] = opts.delete(:replica_port)
49
- opts[:driver] = opts.delete(:original_driver)
50
59
  opts
51
60
  end
52
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