rails_failover 0.4.0 → 0.5.0
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/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
|