rails_failover 0.4.0 → 0.5.0
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/Gemfile +6 -12
- data/Gemfile.lock +1 -9
- data/README.md +46 -7
- data/lib/rails_failover/active_record.rb +17 -4
- data/lib/rails_failover/active_record/handler.rb +26 -56
- data/lib/rails_failover/active_record/middleware.rb +68 -27
- data/lib/rails_failover/active_record/railtie.rb +18 -5
- data/lib/rails_failover/redis.rb +23 -15
- data/lib/rails_failover/redis/connector.rb +10 -5
- data/lib/rails_failover/redis/handler.rb +136 -46
- data/lib/rails_failover/version.rb +1 -1
- data/makefile +1 -1
- data/rails_failover.gemspec +0 -2
- data/redis.mk +6 -6
- metadata +3 -17
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6ab1bfea9c1e882693a3455a73601edce7c0ed2af5b50ec1276cf95e1dda51d5
|
4
|
+
data.tar.gz: 4cf4386f6d51dbae3f82bedcb56c8da3ac4674b254a1b475e2822b46d4a5b8ed
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4b22d9f037c18ca0c82a61764494e5b8a3a41a30a514b12f8d9dec1737e24fb251729457abb8e0f3ab596ab511db98e776740b7a62646313dd542c10c945cbce
|
7
|
+
data.tar.gz: a653959c37717e01e30ff82c0b1594b71e2a402c13073c6e0bf1ee6176d588dc2fcd3c165cd69e294c7fbe0b66bc0e944e36e5fb176b7306ac92dc028a15fb0b
|
data/.rubocop.yml
CHANGED
data/Gemfile
CHANGED
@@ -6,15 +6,9 @@ gemspec
|
|
6
6
|
|
7
7
|
gem "rake", "~> 12.0"
|
8
8
|
gem "rspec", "~> 3.0"
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
gem 'byebug'
|
16
|
-
gem 'redis', '~> 4.1'
|
17
|
-
gem 'pg', '~> 1.2'
|
18
|
-
gem 'activerecord', '~> 6.0'
|
19
|
-
gem 'rack'
|
20
|
-
end
|
9
|
+
gem 'rubocop-discourse'
|
10
|
+
gem 'byebug'
|
11
|
+
gem 'redis', '~> 4.1'
|
12
|
+
gem 'pg', '~> 1.2'
|
13
|
+
gem 'activerecord', '~> 6.0'
|
14
|
+
gem 'rack'
|
data/Gemfile.lock
CHANGED
@@ -1,9 +1,8 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
rails_failover (0.
|
4
|
+
rails_failover (0.5.0)
|
5
5
|
activerecord (~> 6.0)
|
6
|
-
listen (~> 3.2)
|
7
6
|
railties (~> 6.0)
|
8
7
|
|
9
8
|
GEM
|
@@ -40,12 +39,8 @@ GEM
|
|
40
39
|
crass (1.0.6)
|
41
40
|
diff-lcs (1.3)
|
42
41
|
erubi (1.9.0)
|
43
|
-
ffi (1.12.2)
|
44
42
|
i18n (1.8.2)
|
45
43
|
concurrent-ruby (~> 1.0)
|
46
|
-
listen (3.2.1)
|
47
|
-
rb-fsevent (~> 0.10, >= 0.10.3)
|
48
|
-
rb-inotify (~> 0.9, >= 0.9.10)
|
49
44
|
loofah (2.5.0)
|
50
45
|
crass (~> 1.0.2)
|
51
46
|
nokogiri (>= 1.5.9)
|
@@ -74,9 +69,6 @@ GEM
|
|
74
69
|
thor (>= 0.20.3, < 2.0)
|
75
70
|
rainbow (3.0.0)
|
76
71
|
rake (12.3.3)
|
77
|
-
rb-fsevent (0.10.4)
|
78
|
-
rb-inotify (0.10.1)
|
79
|
-
ffi (~> 1.0)
|
80
72
|
redis (4.1.4)
|
81
73
|
rexml (3.2.4)
|
82
74
|
rspec (3.9.0)
|
data/README.md
CHANGED
@@ -1,13 +1,16 @@
|
|
1
1
|
# RailsFailover
|
2
2
|
|
3
|
-
|
3
|
+
Automatic failover and recovery for primary/replica setup for:
|
4
|
+
|
5
|
+
1. Redis
|
6
|
+
1. ActiveRecord (PostgreSQL/MySQL)
|
4
7
|
|
5
8
|
## Installation
|
6
9
|
|
7
10
|
Add this line to your application's Gemfile:
|
8
11
|
|
9
12
|
```ruby
|
10
|
-
gem 'rails_failover'
|
13
|
+
gem 'rails_failover', require: false
|
11
14
|
```
|
12
15
|
|
13
16
|
And then execute:
|
@@ -36,21 +39,58 @@ production:
|
|
36
39
|
|
37
40
|
The gem will automatically create an `ActiveRecord::ConnectionAdapters::ConnectionHandler` with the `ActiveRecord::Base.reading_role` as the `handler_key`.
|
38
41
|
|
42
|
+
#### Failover/Fallback Hooks
|
43
|
+
|
44
|
+
```
|
45
|
+
RailsFailover::ActiveRecord.on_failover do
|
46
|
+
# Enable readonly mode
|
47
|
+
end
|
48
|
+
|
49
|
+
RailsFailover::ActiveRecord.on_fallback do
|
50
|
+
# Disable readonly mode
|
51
|
+
end
|
52
|
+
```
|
53
|
+
|
54
|
+
#### Multiple connection handlers
|
55
|
+
|
56
|
+
Note: This API is unstable and is likely to changes when Rails 6.1 is released with sharding support.
|
57
|
+
|
58
|
+
```
|
59
|
+
# config/database.yml
|
60
|
+
|
61
|
+
production:
|
62
|
+
primary:
|
63
|
+
host: <primary db server host>
|
64
|
+
port: <primary db server port>
|
65
|
+
replica_host: <replica db server host>
|
66
|
+
replica_port: <replica db server port>
|
67
|
+
second_database_writing:
|
68
|
+
host: <primary db server host>
|
69
|
+
port: <primary db server port>
|
70
|
+
replica_host: <replica db server host>
|
71
|
+
replica_port: <replica db server port>
|
72
|
+
|
73
|
+
# In your ActiveRecord base model or model.
|
74
|
+
|
75
|
+
connects_to database: { writing: :primary, second_database_writing: :second_database_writing
|
76
|
+
```
|
77
|
+
|
39
78
|
### Redis
|
40
79
|
|
80
|
+
Add `require 'rails_failover/redis'` before creating a `Redis` instance.
|
81
|
+
|
41
82
|
```
|
42
83
|
Redis.new(host: "127.0.0.1", port: 6379, replica_host: "127.0.0.1", replica_port: 6380, connector: RailsFailover::Redis::Connector))
|
43
84
|
```
|
44
85
|
|
45
|
-
Callbacks can be registered when the
|
46
|
-
|
86
|
+
Callbacks can be registered when the primary connection is down and when it is up.
|
47
87
|
|
48
88
|
```
|
49
|
-
RailsFailover::Redis.
|
89
|
+
RailsFailover::Redis.on_failover_callback do
|
50
90
|
# Switch site to read-only mode
|
51
91
|
end
|
52
92
|
|
53
|
-
RailsFailover::Redis.
|
93
|
+
RailsFailover::Redis.on_fallback_callback do
|
54
94
|
# Switch site out of read-only mode
|
55
95
|
end
|
56
96
|
```
|
@@ -79,7 +119,6 @@ The ActiveRecord failover tests are run against a dummy Rails server. Run the fo
|
|
79
119
|
|
80
120
|
Bug reports and pull requests are welcome on GitHub at https://github.com/discourse/rails_failover. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/discourse/rails_failover/blob/master/CODE_OF_CONDUCT.md).
|
81
121
|
|
82
|
-
|
83
122
|
## License
|
84
123
|
|
85
124
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -27,17 +27,14 @@ module RailsFailover
|
|
27
27
|
@verify_primary_frequency_seconds || 5
|
28
28
|
end
|
29
29
|
|
30
|
-
def self.establish_reading_connection(connection_spec)
|
30
|
+
def self.establish_reading_connection(handler, connection_spec)
|
31
31
|
config = connection_spec.config
|
32
32
|
|
33
33
|
if config[:replica_host] && config[:replica_port]
|
34
34
|
replica_config = config.dup
|
35
|
-
|
36
35
|
replica_config[:host] = replica_config.delete(:replica_host)
|
37
36
|
replica_config[:port] = replica_config.delete(:replica_port)
|
38
37
|
replica_config[:replica] = true
|
39
|
-
|
40
|
-
handler = ::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.reading_role]
|
41
38
|
handler.establish_connection(replica_config)
|
42
39
|
end
|
43
40
|
end
|
@@ -45,5 +42,21 @@ module RailsFailover
|
|
45
42
|
def self.register_force_reading_role_callback(&block)
|
46
43
|
Middleware.force_reading_role_callback = block
|
47
44
|
end
|
45
|
+
|
46
|
+
def self.on_failover(&block)
|
47
|
+
@on_failover_callback = block
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.on_failover_callback
|
51
|
+
@on_failover_callback
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.on_fallback(&block)
|
55
|
+
@on_fallback_callback = block
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.on_fallback_callback
|
59
|
+
@on_fallback_callback
|
60
|
+
end
|
48
61
|
end
|
49
62
|
end
|
@@ -1,7 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
require 'singleton'
|
3
3
|
require 'monitor'
|
4
|
-
require 'listen'
|
5
4
|
require 'fileutils'
|
6
5
|
|
7
6
|
module RailsFailover
|
@@ -17,54 +16,39 @@ module RailsFailover
|
|
17
16
|
@primaries_down = {}
|
18
17
|
@ancestor_pid = Process.pid
|
19
18
|
|
20
|
-
@dir = '/tmp/rails_failover'
|
21
|
-
FileUtils.remove_dir(@dir) if Dir.exists?(@dir)
|
22
|
-
FileUtils.mkdir_p(@dir)
|
23
|
-
|
24
|
-
@listener = Listen.to(@dir) do |modified, added, removed|
|
25
|
-
if added.length > 0
|
26
|
-
added.each do |f|
|
27
|
-
pid, handler_key = File.basename(f).split(SEPERATOR)
|
28
|
-
|
29
|
-
if Process.pid != pid
|
30
|
-
verify_primary(handler_key.to_sym, publish: false)
|
31
|
-
end
|
32
|
-
end
|
33
|
-
end
|
34
|
-
|
35
|
-
if removed.length > 0
|
36
|
-
removed.each do |f|
|
37
|
-
pid, handler_key = File.basename(f).split(SEPERATOR)
|
38
|
-
|
39
|
-
if Process.pid != pid
|
40
|
-
primary_up(handler_key.to_sym)
|
41
|
-
end
|
42
|
-
end
|
43
|
-
end
|
44
|
-
end
|
45
|
-
|
46
19
|
super() # Monitor#initialize
|
47
20
|
end
|
48
21
|
|
49
|
-
def
|
50
|
-
@listener.start
|
51
|
-
end
|
52
|
-
|
53
|
-
def verify_primary(handler_key, publish: true)
|
22
|
+
def verify_primary(handler_key)
|
54
23
|
mon_synchronize do
|
55
24
|
primary_down(handler_key)
|
56
|
-
|
57
|
-
|
25
|
+
return if @thread&.alive?
|
26
|
+
|
27
|
+
logger.warn "Failover for ActiveRecord has been initiated"
|
28
|
+
|
29
|
+
begin
|
30
|
+
RailsFailover::ActiveRecord.on_failover_callback&.call
|
31
|
+
rescue => e
|
32
|
+
logger.warn("RailsFailover::ActiveRecord.on_failover_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
|
33
|
+
end
|
58
34
|
|
59
35
|
@thread = Thread.new do
|
60
36
|
loop do
|
61
37
|
initiate_fallback_to_primary
|
62
|
-
|
38
|
+
|
39
|
+
if all_primaries_up
|
40
|
+
logger.warn "Fallback to primary for ActiveRecord has been completed."
|
41
|
+
|
42
|
+
begin
|
43
|
+
RailsFailover::ActiveRecord.on_fallback_callback&.call
|
44
|
+
rescue => e
|
45
|
+
logger.warn("RailsFailover::ActiveRecord.on_fallback_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
|
46
|
+
end
|
47
|
+
|
48
|
+
break
|
49
|
+
end
|
63
50
|
end
|
64
51
|
end
|
65
|
-
|
66
|
-
@thread["pid"] = Process.pid
|
67
|
-
@thread
|
68
52
|
end
|
69
53
|
end
|
70
54
|
|
@@ -78,27 +62,26 @@ module RailsFailover
|
|
78
62
|
connection_handler = ::ActiveRecord::Base.connection_handlers[handler_key]
|
79
63
|
spec = connection_handler.retrieve_connection_pool(spec_name).spec
|
80
64
|
config = spec.config
|
81
|
-
logger.
|
65
|
+
logger.debug "#{Process.pid} Checking server for '#{handler_key} #{spec_name}'..."
|
82
66
|
connection_active = false
|
83
67
|
|
84
68
|
begin
|
85
69
|
connection = ::ActiveRecord::Base.public_send(spec.adapter_method, config)
|
86
70
|
connection_active = connection.active?
|
87
71
|
rescue => e
|
88
|
-
logger.
|
72
|
+
logger.debug "#{Process.pid} Connection to server for '#{handler_key} #{spec_name}' failed with '#{e.message}'"
|
89
73
|
ensure
|
90
74
|
connection.disconnect! if connection
|
91
75
|
end
|
92
76
|
|
93
77
|
if connection_active
|
94
|
-
logger.
|
78
|
+
logger.debug "#{Process.pid} Server for '#{handler_key} #{spec_name}' is active."
|
95
79
|
active_handler_keys << handler_key
|
96
80
|
end
|
97
81
|
end
|
98
82
|
|
99
83
|
active_handler_keys.each do |handler_key|
|
100
84
|
primary_up(handler_key)
|
101
|
-
publish_primary_up(handler_key)
|
102
85
|
end
|
103
86
|
end
|
104
87
|
|
@@ -114,25 +97,12 @@ module RailsFailover
|
|
114
97
|
end
|
115
98
|
end
|
116
99
|
|
117
|
-
|
118
100
|
def primary_down(handler_key)
|
119
101
|
mon_synchronize do
|
120
102
|
primaries_down[handler_key] = true
|
121
103
|
end
|
122
104
|
end
|
123
105
|
|
124
|
-
def publish_primary_down(handler_key)
|
125
|
-
FileUtils.touch("#{@dir}/#{Process.pid}#{SEPERATOR}#{handler_key}")
|
126
|
-
end
|
127
|
-
|
128
|
-
def publish_primary_up(handler_key)
|
129
|
-
path = "#{@dir}/#{Process.pid}#{SEPERATOR}#{handler_key}"
|
130
|
-
|
131
|
-
if File.exists?(path)
|
132
|
-
FileUtils.rm("#{@dir}/#{Process.pid}#{SEPERATOR}#{handler_key}")
|
133
|
-
end
|
134
|
-
end
|
135
|
-
|
136
106
|
def primary_up(handler_key)
|
137
107
|
mon_synchronize do
|
138
108
|
primaries_down.delete(handler_key)
|
@@ -165,7 +135,7 @@ module RailsFailover
|
|
165
135
|
end
|
166
136
|
|
167
137
|
def logger
|
168
|
-
Rails.logger
|
138
|
+
::Rails.logger
|
169
139
|
end
|
170
140
|
end
|
171
141
|
end
|
@@ -2,57 +2,98 @@
|
|
2
2
|
|
3
3
|
module RailsFailover
|
4
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
|
+
|
5
31
|
class Middleware
|
6
32
|
class << self
|
7
33
|
attr_accessor :force_reading_role_callback
|
8
|
-
|
9
|
-
def adapter_error
|
10
|
-
@adapter_error ||= begin
|
11
|
-
if defined?(::PG)
|
12
|
-
::PG::Error
|
13
|
-
elsif defined?(::SQLite3)
|
14
|
-
::SQLite3::Exception
|
15
|
-
elsif defined?(::Mysql2)
|
16
|
-
::Mysql2::Error
|
17
|
-
end
|
18
|
-
end
|
19
|
-
end
|
20
34
|
end
|
21
35
|
|
22
|
-
|
36
|
+
CURRENT_ROLE_HEADER = "rails_failover.role"
|
37
|
+
WRITING_ROLE_HEADER = "rails_failover.writing_role"
|
23
38
|
|
24
39
|
def initialize(app)
|
25
40
|
@app = app
|
26
41
|
end
|
27
42
|
|
28
43
|
def call(env)
|
29
|
-
|
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)
|
30
47
|
|
31
48
|
role =
|
32
|
-
if primary_down =
|
33
|
-
|
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
|
34
53
|
else
|
35
|
-
|
54
|
+
writing_role
|
36
55
|
end
|
37
56
|
|
38
57
|
::ActiveRecord::Base.connected_to(role: role) do
|
39
|
-
env[
|
58
|
+
env[CURRENT_ROLE_HEADER] = role
|
59
|
+
env[WRITING_ROLE_HEADER] = writing_role
|
40
60
|
@app.call(env)
|
41
61
|
end
|
42
|
-
rescue Exception => e
|
43
|
-
if (resolve_cause(e).is_a?(self.class.adapter_error))
|
44
|
-
Handler.instance.verify_primary(writing_role)
|
45
|
-
raise
|
46
|
-
end
|
47
62
|
end
|
48
63
|
|
49
64
|
private
|
50
65
|
|
51
|
-
def
|
52
|
-
|
53
|
-
|
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
|
54
95
|
else
|
55
|
-
|
96
|
+
current_role
|
56
97
|
end
|
57
98
|
end
|
58
99
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module RailsFailover
|
2
4
|
module ActiveRecord
|
3
5
|
class Railtie < ::Rails::Railtie
|
@@ -11,7 +13,10 @@ module RailsFailover
|
|
11
13
|
::ActiveRecord::ConnectionAdapters::ConnectionHandler.new
|
12
14
|
|
13
15
|
::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.writing_role].connection_pools.each do |connection_pool|
|
14
|
-
RailsFailover::ActiveRecord.establish_reading_connection(
|
16
|
+
RailsFailover::ActiveRecord.establish_reading_connection(
|
17
|
+
::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.reading_role],
|
18
|
+
connection_pool.spec
|
19
|
+
)
|
15
20
|
end
|
16
21
|
|
17
22
|
begin
|
@@ -26,10 +31,18 @@ module RailsFailover
|
|
26
31
|
end
|
27
32
|
|
28
33
|
initializer "rails_failover.insert_middleware" do |app|
|
29
|
-
|
30
|
-
::
|
31
|
-
|
32
|
-
|
34
|
+
ActionDispatch::DebugExceptions.register_interceptor do |request, exception|
|
35
|
+
RailsFailover::ActiveRecord::Interceptor.handle(request, exception)
|
36
|
+
end
|
37
|
+
|
38
|
+
if !skip_middleware?(app.config)
|
39
|
+
app.middleware.unshift(::RailsFailover::ActiveRecord::Middleware)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def skip_middleware?(config)
|
44
|
+
return false if !config.respond_to?(:skip_rails_failover_active_record_middleware)
|
45
|
+
config.skip_rails_failover_active_record_middleware
|
33
46
|
end
|
34
47
|
end
|
35
48
|
end
|
data/lib/rails_failover/redis.rb
CHANGED
@@ -18,33 +18,41 @@ module RailsFailover
|
|
18
18
|
end
|
19
19
|
|
20
20
|
def self.logger
|
21
|
-
@logger
|
21
|
+
if @logger
|
22
|
+
@logger
|
23
|
+
elsif defined?(::Rails)
|
24
|
+
::Rails.logger
|
25
|
+
end
|
22
26
|
end
|
23
27
|
|
24
|
-
def self.
|
25
|
-
@
|
28
|
+
def self.verify_primary_frequency_seconds=(seconds)
|
29
|
+
@verify_primary_frequency_seconds = seconds
|
26
30
|
end
|
27
31
|
|
28
|
-
def self.
|
29
|
-
@
|
32
|
+
def self.verify_primary_frequency_seconds
|
33
|
+
@verify_primary_frequency_seconds || 5
|
30
34
|
end
|
31
35
|
|
32
|
-
def self.
|
33
|
-
@
|
34
|
-
@master_down_callbacks.push(block)
|
36
|
+
def self.on_failover(&block)
|
37
|
+
@on_failover_callback = block
|
35
38
|
end
|
36
39
|
|
37
|
-
def self.
|
38
|
-
@
|
40
|
+
def self.on_failover_callback
|
41
|
+
@on_failover_callback
|
39
42
|
end
|
40
43
|
|
41
|
-
def self.
|
42
|
-
@
|
43
|
-
@master_up_callbacks.push(block)
|
44
|
+
def self.on_fallback(&block)
|
45
|
+
@on_fallback_callback = block
|
44
46
|
end
|
45
47
|
|
46
|
-
def self.
|
47
|
-
@
|
48
|
+
def self.on_fallback_callback
|
49
|
+
@on_fallback_callback
|
50
|
+
end
|
51
|
+
|
52
|
+
# For testing
|
53
|
+
def self.clear_callbacks
|
54
|
+
@on_fallback_callback = nil
|
55
|
+
@on_failover_callback = nil
|
48
56
|
end
|
49
57
|
end
|
50
58
|
end
|
@@ -6,7 +6,7 @@ module RailsFailover
|
|
6
6
|
class Redis
|
7
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,18 +22,24 @@ module RailsFailover
|
|
22
22
|
Errno::ETIMEDOUT,
|
23
23
|
Errno::EINVAL => e
|
24
24
|
|
25
|
-
Handler.instance.
|
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
|
-
Handler.instance.
|
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)
|
@@ -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
|
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require 'monitor'
|
4
4
|
require 'singleton'
|
5
|
+
require 'digest'
|
5
6
|
|
6
7
|
module RailsFailover
|
7
8
|
class Redis
|
@@ -9,100 +10,189 @@ module RailsFailover
|
|
9
10
|
include Singleton
|
10
11
|
include MonitorMixin
|
11
12
|
|
12
|
-
|
13
|
-
|
13
|
+
PRIMARY_ROLE_STATUS = "role:master"
|
14
|
+
PRIMARY_LOADED_STATUS = "loading:0"
|
15
|
+
VERIFY_FREQUENCY_BUFFER_PRECENT = 20
|
16
|
+
SEPERATOR = "__RAILS_FAILOVER__"
|
14
17
|
|
15
18
|
def initialize
|
16
|
-
@
|
17
|
-
@clients =
|
19
|
+
@primaries_down = {}
|
20
|
+
@clients = {}
|
21
|
+
@ancestor_pid = Process.pid
|
18
22
|
|
19
23
|
super() # Monitor#initialize
|
20
24
|
end
|
21
25
|
|
22
|
-
def
|
26
|
+
def verify_primary(options)
|
23
27
|
mon_synchronize do
|
28
|
+
primary_down(options)
|
29
|
+
disconnect_clients(options)
|
30
|
+
|
24
31
|
return if @thread&.alive?
|
25
32
|
|
26
|
-
|
27
|
-
|
28
|
-
|
33
|
+
logger&.warn "Failover for Redis has been initiated"
|
34
|
+
|
35
|
+
begin
|
36
|
+
RailsFailover::Redis.on_failover_callback&.call
|
37
|
+
rescue => e
|
38
|
+
logger&.warn("RailsFailover::Redis.on_failover_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
|
39
|
+
end
|
29
40
|
|
30
41
|
@thread = Thread.new do
|
31
42
|
loop do
|
32
|
-
thread = Thread.new {
|
43
|
+
thread = Thread.new { initiate_fallback_to_primary }
|
33
44
|
thread.join
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
45
|
+
|
46
|
+
if all_primaries_up
|
47
|
+
logger&.warn "Fallback to primary for Redis has been completed."
|
48
|
+
|
49
|
+
begin
|
50
|
+
RailsFailover::Redis.on_fallback_callback&.call
|
51
|
+
rescue => e
|
52
|
+
logger&.warn("RailsFailover::Redis.on_fallback_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
|
53
|
+
end
|
54
|
+
|
55
|
+
break
|
56
|
+
end
|
38
57
|
end
|
39
58
|
end
|
40
59
|
end
|
41
60
|
end
|
42
61
|
|
43
|
-
def
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
62
|
+
def initiate_fallback_to_primary
|
63
|
+
frequency = RailsFailover::Redis.verify_primary_frequency_seconds
|
64
|
+
sleep(frequency * ((rand(VERIFY_FREQUENCY_BUFFER_PRECENT) + 100) / 100.0))
|
65
|
+
|
66
|
+
active_primaries_keys = {}
|
67
|
+
|
68
|
+
primaries_down.each do |key, options|
|
69
|
+
info = nil
|
70
|
+
options = options.dup
|
71
|
+
|
72
|
+
begin
|
73
|
+
options[:driver] = options[:original_driver]
|
74
|
+
primary_client = ::Redis::Client.new(options)
|
75
|
+
logger&.debug "Checking connection to primary server (#{key})"
|
76
|
+
info = primary_client.call([:info])
|
77
|
+
rescue => e
|
78
|
+
logger&.debug "Connection to primary server (#{key}) failed with '#{e.message}'"
|
79
|
+
ensure
|
80
|
+
primary_client&.disconnect
|
81
|
+
end
|
82
|
+
|
83
|
+
if info && info.include?(PRIMARY_LOADED_STATUS) && info.include?(PRIMARY_ROLE_STATUS)
|
84
|
+
active_primaries_keys[key] = options
|
85
|
+
logger&.debug "Primary server (#{key}) is active, disconnecting clients from replica"
|
86
|
+
end
|
54
87
|
end
|
55
88
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
disconnect_clients
|
60
|
-
RailsFailover::Redis.master_up_callbacks.each { |callback| callback.call }
|
89
|
+
active_primaries_keys.each do |key, options|
|
90
|
+
primary_up(options)
|
91
|
+
disconnect_clients(options)
|
61
92
|
end
|
62
93
|
end
|
63
94
|
|
64
95
|
def register_client(client)
|
96
|
+
key = client.options[:id]
|
97
|
+
|
65
98
|
mon_synchronize do
|
66
|
-
|
99
|
+
clients[key] ||= []
|
100
|
+
clients[key] << client
|
67
101
|
end
|
68
102
|
end
|
69
103
|
|
70
104
|
def deregister_client(client)
|
105
|
+
key = client.options[:id]
|
106
|
+
|
71
107
|
mon_synchronize do
|
72
|
-
|
108
|
+
if clients[key]
|
109
|
+
clients[key].delete(client)
|
110
|
+
|
111
|
+
if clients[key].empty?
|
112
|
+
clients.delete(key)
|
113
|
+
end
|
114
|
+
end
|
73
115
|
end
|
74
116
|
end
|
75
117
|
|
76
|
-
def
|
77
|
-
mon_synchronize
|
118
|
+
def primary_down?(options)
|
119
|
+
mon_synchronize do
|
120
|
+
primaries_down[options[:id]]
|
121
|
+
end
|
78
122
|
end
|
79
123
|
|
80
|
-
|
81
|
-
|
124
|
+
private
|
125
|
+
|
126
|
+
def id_digest(id)
|
127
|
+
Digest::MD5.hexdigest(id)
|
82
128
|
end
|
83
129
|
|
84
|
-
def
|
85
|
-
mon_synchronize {
|
130
|
+
def all_primaries_up
|
131
|
+
mon_synchronize { primaries_down.empty? }
|
86
132
|
end
|
87
133
|
|
88
|
-
|
134
|
+
def primary_up(options)
|
135
|
+
mon_synchronize do
|
136
|
+
primaries_down.delete(options[:id])
|
137
|
+
end
|
138
|
+
end
|
89
139
|
|
90
|
-
def
|
140
|
+
def primary_down(options)
|
91
141
|
mon_synchronize do
|
92
|
-
|
93
|
-
|
142
|
+
primaries_down[options[:id]] = options.dup
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def clients
|
147
|
+
process_pid = Process.pid
|
148
|
+
return @clients[process_pid] if @clients[process_pid]
|
149
|
+
|
150
|
+
mon_synchronize do
|
151
|
+
if !@clients[process_pid]
|
152
|
+
@clients[process_pid] = {}
|
153
|
+
|
154
|
+
if process_pid != @ancestor_pid
|
155
|
+
@clients.delete(@ancestor_pid)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
@clients[process_pid]
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def primaries_down
|
164
|
+
process_pid = Process.pid
|
165
|
+
return @primaries_down[process_pid] if @primaries_down[process_pid]
|
166
|
+
|
167
|
+
mon_synchronize do
|
168
|
+
if !@primaries_down[process_pid]
|
169
|
+
@primaries_down[process_pid] = @primaries_down[@ancestor_pid] || {}
|
170
|
+
|
171
|
+
if process_pid != @ancestor_pid
|
172
|
+
@primaries_down.delete(@ancestor_pid).each do |id, options|
|
173
|
+
verify_primary(options)
|
174
|
+
end
|
175
|
+
end
|
94
176
|
end
|
177
|
+
|
178
|
+
@primaries_down[process_pid]
|
95
179
|
end
|
96
180
|
end
|
97
181
|
|
98
|
-
def
|
99
|
-
|
100
|
-
|
182
|
+
def disconnect_clients(options)
|
183
|
+
key = options[:id]
|
184
|
+
|
185
|
+
mon_synchronize do
|
186
|
+
if clients[key]
|
187
|
+
clients[key].dup.each do |c|
|
188
|
+
c.disconnect
|
189
|
+
end
|
190
|
+
end
|
101
191
|
end
|
102
192
|
end
|
103
193
|
|
104
|
-
def
|
105
|
-
|
194
|
+
def logger
|
195
|
+
RailsFailover::Redis.logger
|
106
196
|
end
|
107
197
|
end
|
108
198
|
end
|
data/makefile
CHANGED
@@ -12,7 +12,7 @@ setup_dummy_rails_server:
|
|
12
12
|
@cd spec/support/dummy_app && bundle install --quiet --without test --without development && yarn install && RAILS_ENV=production $(BUNDLER_BIN) exec rails db:create db:migrate db:seed
|
13
13
|
|
14
14
|
start_dummy_rails_server:
|
15
|
-
@cd spec/support/dummy_app && BUNDLE_GEMFILE=Gemfile bundle exec unicorn -c config/unicorn.conf.rb -D -E production
|
15
|
+
@cd spec/support/dummy_app && BUNDLE_GEMFILE=Gemfile UNICORN_WORKERS=5 SECRET_KEY_BASE=somekey bundle exec unicorn -c config/unicorn.conf.rb -D -E production
|
16
16
|
|
17
17
|
stop_dummy_rails_server:
|
18
18
|
@kill -TERM $(shell cat spec/support/dummy_app/tmp/pids/unicorn.pid)
|
data/rails_failover.gemspec
CHANGED
@@ -21,8 +21,6 @@ Gem::Specification.new do |spec|
|
|
21
21
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
22
22
|
spec.require_paths = ["lib"]
|
23
23
|
|
24
|
-
spec.add_dependency 'listen', "~> 3.2"
|
25
|
-
|
26
24
|
["activerecord", "railties"].each do |gem_name|
|
27
25
|
spec.add_dependency gem_name, "~> 6.0"
|
28
26
|
end
|
data/redis.mk
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
REDIS_PORT := 6381
|
2
2
|
REDIS_PID_PATH := /tmp/redis.pid
|
3
3
|
REDIS_SOCKET_PATH := /tmp/redis.sock
|
4
|
-
REDIS_DBFILENAME :=
|
4
|
+
REDIS_DBFILENAME := primary.rdb
|
5
5
|
REDIS_REPLICA_PORT := 6382
|
6
6
|
REDIS_REPLICA_PID_PATH := /tmp/redis_replica.pid
|
7
7
|
REDIS_REPLICA_SOCKET_PATH := /tmp/redis_replica.sock
|
@@ -12,17 +12,17 @@ redis: start_redis test_redis stop_redis
|
|
12
12
|
test_redis:
|
13
13
|
@REDIS=1 bundle exec rspec --tag type:redis ${RSPEC_PATH}
|
14
14
|
|
15
|
-
start_redis:
|
16
|
-
stop_redis: stop_redis_replica
|
15
|
+
start_redis: start_redis_primary start_redis_replica
|
16
|
+
stop_redis: stop_redis_replica stop_redis_primary
|
17
17
|
|
18
|
-
|
18
|
+
stop_redis_primary:
|
19
19
|
@redis-cli -p ${REDIS_PORT} shutdown
|
20
20
|
|
21
|
-
|
21
|
+
start_redis_primary:
|
22
22
|
@redis-server --daemonize yes --pidfile ${REDIS_PID_PATH} --port ${REDIS_PORT} --unixsocket ${REDIS_SOCKET_PATH} --dbfilename ${REDIS_DBFILENAME} --logfile /dev/null
|
23
23
|
|
24
24
|
stop_redis_replica:
|
25
25
|
@redis-cli -p ${REDIS_REPLICA_PORT} shutdown
|
26
26
|
|
27
27
|
start_redis_replica:
|
28
|
-
@redis-server --daemonize yes --pidfile ${REDIS_REPLICA_PID_PATH} --port ${REDIS_REPLICA_PORT} --unixsocket ${REDIS_REPLICA_SOCKET_PATH} --
|
28
|
+
@redis-server --daemonize yes --pidfile ${REDIS_REPLICA_PID_PATH} --port ${REDIS_REPLICA_PORT} --unixsocket ${REDIS_REPLICA_SOCKET_PATH} --slaveof 127.0.0.1 ${REDIS_PORT} --dbfilename ${REDIS_REPLICA_DBFILENAME} --logfile /dev/null
|
metadata
CHANGED
@@ -1,29 +1,15 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rails_failover
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Alan Tan
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-06-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
-
- !ruby/object:Gem::Dependency
|
14
|
-
name: listen
|
15
|
-
requirement: !ruby/object:Gem::Requirement
|
16
|
-
requirements:
|
17
|
-
- - "~>"
|
18
|
-
- !ruby/object:Gem::Version
|
19
|
-
version: '3.2'
|
20
|
-
type: :runtime
|
21
|
-
prerelease: false
|
22
|
-
version_requirements: !ruby/object:Gem::Requirement
|
23
|
-
requirements:
|
24
|
-
- - "~>"
|
25
|
-
- !ruby/object:Gem::Version
|
26
|
-
version: '3.2'
|
27
13
|
- !ruby/object:Gem::Dependency
|
28
14
|
name: activerecord
|
29
15
|
requirement: !ruby/object:Gem::Requirement
|
@@ -105,7 +91,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
105
91
|
- !ruby/object:Gem::Version
|
106
92
|
version: '0'
|
107
93
|
requirements: []
|
108
|
-
rubygems_version: 3.
|
94
|
+
rubygems_version: 3.1.2
|
109
95
|
signing_key:
|
110
96
|
specification_version: 4
|
111
97
|
summary: Failover for ActiveRecord and Redis
|