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.
- 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
|