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.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +6 -5
- data/Gemfile.lock +65 -2
- data/README.md +79 -10
- data/bin/console +2 -1
- data/bin/rspec +1 -1
- data/lib/rails_failover.rb +0 -2
- data/lib/rails_failover/active_record.rb +62 -0
- data/lib/rails_failover/active_record/handler.rb +140 -0
- data/lib/rails_failover/active_record/middleware.rb +103 -0
- data/lib/rails_failover/active_record/railtie.rb +58 -0
- data/lib/rails_failover/redis.rb +32 -15
- data/lib/rails_failover/redis/connector.rb +14 -9
- data/lib/rails_failover/redis/handler.rb +193 -0
- data/lib/rails_failover/version.rb +1 -1
- data/makefile +14 -24
- data/postgresql.mk +54 -0
- data/rails_failover.gemspec +4 -2
- data/redis.mk +28 -0
- metadata +29 -8
- data/lib/rails_failover/redis/failover_handler.rb +0 -109
@@ -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
|
data/lib/rails_failover/redis.rb
CHANGED
@@ -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.
|
16
|
-
@
|
32
|
+
def self.verify_primary_frequency_seconds
|
33
|
+
@verify_primary_frequency_seconds || 5
|
17
34
|
end
|
18
35
|
|
19
|
-
def self.
|
20
|
-
@
|
36
|
+
def self.on_failover(&block)
|
37
|
+
@on_failover_callback = block
|
21
38
|
end
|
22
39
|
|
23
|
-
def self.
|
24
|
-
@
|
25
|
-
@master_down_callbacks.push(block)
|
40
|
+
def self.on_failover_callback
|
41
|
+
@on_failover_callback
|
26
42
|
end
|
27
43
|
|
28
|
-
def self.
|
29
|
-
@
|
44
|
+
def self.on_fallback(&block)
|
45
|
+
@on_fallback_callback = block
|
30
46
|
end
|
31
47
|
|
32
|
-
def self.
|
33
|
-
@
|
34
|
-
@master_up_callbacks.push(block)
|
48
|
+
def self.on_fallback_callback
|
49
|
+
@on_fallback_callback
|
35
50
|
end
|
36
51
|
|
37
|
-
|
38
|
-
|
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 '
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
46
|
+
Handler.instance.register_client(client)
|
41
47
|
end
|
42
48
|
|
43
49
|
def on_disconnect(client)
|
44
|
-
|
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
|