rails_failover 0.6.0 → 0.6.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -1
- data/Gemfile.lock +3 -2
- data/lib/rails_failover/active_record/handler.rb +33 -50
- data/lib/rails_failover/active_record/middleware.rb +11 -1
- data/lib/rails_failover/redis/connector.rb +10 -4
- data/lib/rails_failover/redis/handler.rb +63 -97
- data/lib/rails_failover/version.rb +1 -1
- data/makefile +1 -1
- data/rails_failover.gemspec +1 -0
- metadata +22 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 45afa97c24a3c8f4fac5c74a52eabbf0d06b9f7ad037a76b18bee05875b4f374
|
4
|
+
data.tar.gz: e57ffc9182a9b2f4145c04d4f921bcd79aee3edfec5dedacac2fb6f23d39dc9d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0ee4ae4e79e17881a49f77711b5f13f301977d67cd177f8d16296cff93c0aea63b9151f235acfde87afaae14f6215840fbb3efb6024a98b59c14454b88705356
|
7
|
+
data.tar.gz: 48d815e482d5b6b92316431bda654709f3049647292c4a4d9b8a6a075086be2e23b12cceed898d07e10f729bc85217aa395521a49ffd1c4baad63927ed938963
|
data/CHANGELOG.md
CHANGED
@@ -6,6 +6,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
6
6
|
|
7
7
|
## [Unreleased]
|
8
8
|
|
9
|
+
## [0.6.5] - 2020-12-16
|
10
|
+
|
11
|
+
- FIX: Catch exceptions that are not intercepted by `ActionDispatch::DebugExceptions`.
|
12
|
+
|
13
|
+
## [0.6.4] - 2020-12-09
|
14
|
+
|
15
|
+
- FIX: Handle the case when the replica is set equal to the primary
|
16
|
+
|
17
|
+
## [0.6.3] - 2020-12-07
|
18
|
+
|
19
|
+
- FIX: Handle clients which are connecting during fallback
|
20
|
+
|
21
|
+
## [0.6.2] - 2020-11-19
|
22
|
+
|
23
|
+
- FIX: Use concurrent-ruby maps to simplify concurrency logic. Resolves a number of possible concurrency issues
|
24
|
+
|
25
|
+
## [0.6.1] - 2020-11-19
|
26
|
+
|
27
|
+
- FIX: Recover correctly if both the primary and replica go offline
|
28
|
+
|
29
|
+
Previously, a replica failing would cause it to be added to the 'primaries_down' list. The fallback handler would then continuously try and fallback the replica to itself, looping forever, and meaning that fallback to primary would never happen.
|
30
|
+
|
9
31
|
## [0.6.0] - 2020-11-09
|
10
32
|
- FEATURE: Run failover/fallback callbacks once for each backend
|
11
33
|
|
@@ -13,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
13
35
|
|
14
36
|
- FEATURE: Add primaries_down_count function to failover handlers
|
15
37
|
|
16
|
-
This is intended for consumption by monitoring systems (e.g. the Discourse prometheus exporter)
|
38
|
+
This is intended for consumption by monitoring systems (e.g. the Discourse prometheus exporter)
|
17
39
|
|
18
40
|
## [0.5.9] - 2020-11-06
|
19
41
|
- FIX: Ignore errors from the redis socket shutdown call
|
data/Gemfile.lock
CHANGED
@@ -1,8 +1,9 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
rails_failover (0.6.
|
4
|
+
rails_failover (0.6.5)
|
5
5
|
activerecord (~> 6.0)
|
6
|
+
concurrent-ruby
|
6
7
|
railties (~> 6.0)
|
7
8
|
|
8
9
|
GEM
|
@@ -38,7 +39,7 @@ GEM
|
|
38
39
|
concurrent-ruby (1.1.6)
|
39
40
|
crass (1.0.6)
|
40
41
|
diff-lcs (1.3)
|
41
|
-
erubi (1.
|
42
|
+
erubi (1.10.0)
|
42
43
|
i18n (1.8.2)
|
43
44
|
concurrent-ruby (~> 1.0)
|
44
45
|
loofah (2.7.0)
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
require 'singleton'
|
3
3
|
require 'monitor'
|
4
|
+
require 'concurrent'
|
4
5
|
|
5
6
|
module RailsFailover
|
6
7
|
module ActiveRecord
|
@@ -11,8 +12,7 @@ module RailsFailover
|
|
11
12
|
VERIFY_FREQUENCY_BUFFER_PRECENT = 20
|
12
13
|
|
13
14
|
def initialize
|
14
|
-
@primaries_down =
|
15
|
-
@ancestor_pid = Process.pid
|
15
|
+
@primaries_down = Concurrent::Map.new
|
16
16
|
|
17
17
|
super() # Monitor#initialize
|
18
18
|
end
|
@@ -22,18 +22,28 @@ module RailsFailover
|
|
22
22
|
|
23
23
|
mon_synchronize do
|
24
24
|
return if @thread&.alive?
|
25
|
-
|
26
25
|
logger.warn "Failover for ActiveRecord has been initiated"
|
26
|
+
@thread = Thread.new { loop_until_all_up }
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def primary_down?(handler_key)
|
31
|
+
primaries_down[handler_key]
|
32
|
+
end
|
33
|
+
|
34
|
+
def primaries_down_count
|
35
|
+
primaries_down.size
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
27
39
|
|
28
|
-
|
29
|
-
|
30
|
-
|
40
|
+
def loop_until_all_up
|
41
|
+
loop do
|
42
|
+
initiate_fallback_to_primary
|
31
43
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
end
|
36
|
-
end
|
44
|
+
if all_primaries_up
|
45
|
+
logger.warn "Fallback to primary for ActiveRecord has been completed."
|
46
|
+
break
|
37
47
|
end
|
38
48
|
end
|
39
49
|
end
|
@@ -71,37 +81,17 @@ module RailsFailover
|
|
71
81
|
end
|
72
82
|
end
|
73
83
|
|
74
|
-
def primary_down?(handler_key)
|
75
|
-
primaries_down[handler_key]
|
76
|
-
end
|
77
|
-
|
78
|
-
def primaries_down_count
|
79
|
-
mon_synchronize do
|
80
|
-
primaries_down.count
|
81
|
-
end
|
82
|
-
end
|
83
|
-
|
84
|
-
private
|
85
|
-
|
86
84
|
def all_primaries_up
|
87
|
-
|
88
|
-
primaries_down.empty?
|
89
|
-
end
|
85
|
+
primaries_down.empty?
|
90
86
|
end
|
91
87
|
|
92
88
|
def primary_down(handler_key)
|
93
|
-
already_down =
|
94
|
-
mon_synchronize do
|
95
|
-
already_down = !!primaries_down[handler_key]
|
96
|
-
primaries_down[handler_key] = true
|
97
|
-
end
|
89
|
+
already_down = primaries_down.put_if_absent(handler_key, true)
|
98
90
|
RailsFailover::ActiveRecord.on_failover_callback!(handler_key) if !already_down
|
99
91
|
end
|
100
92
|
|
101
93
|
def primary_up(handler_key)
|
102
|
-
already_up =
|
103
|
-
!primaries_down.delete(handler_key)
|
104
|
-
end
|
94
|
+
already_up = !primaries_down.delete(handler_key)
|
105
95
|
RailsFailover::ActiveRecord.on_fallback_callback!(handler_key) if !already_up
|
106
96
|
end
|
107
97
|
|
@@ -110,24 +100,17 @@ module RailsFailover
|
|
110
100
|
end
|
111
101
|
|
112
102
|
def primaries_down
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
@primaries_down[process_pid] = @primaries_down[@ancestor_pid] || {}
|
119
|
-
|
120
|
-
if process_pid != @ancestor_pid
|
121
|
-
@primaries_down.delete(@ancestor_pid)
|
122
|
-
|
123
|
-
@primaries_down[process_pid].each_key do |handler_key|
|
124
|
-
verify_primary(handler_key)
|
125
|
-
end
|
126
|
-
end
|
127
|
-
end
|
103
|
+
ancestor_pids = nil
|
104
|
+
value = @primaries_down.compute_if_absent(Process.pid) do
|
105
|
+
ancestor_pids = @primaries_down.keys
|
106
|
+
@primaries_down.values.first || Concurrent::Map.new
|
107
|
+
end
|
128
108
|
|
129
|
-
|
109
|
+
ancestor_pids&.each do |pid|
|
110
|
+
@primaries_down.delete(pid)&.each_key { |key| verify_primary(key) }
|
130
111
|
end
|
112
|
+
|
113
|
+
value
|
131
114
|
end
|
132
115
|
|
133
116
|
def logger
|
@@ -14,10 +14,17 @@ module RailsFailover
|
|
14
14
|
end
|
15
15
|
|
16
16
|
def self.handle(request, exception)
|
17
|
+
verify_primary(
|
18
|
+
exception,
|
19
|
+
request.env[Middleware::WRITING_ROLE_HEADER]
|
20
|
+
)
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.verify_primary(exception, writing_role)
|
17
24
|
exception = resolve_cause(exception)
|
18
25
|
|
19
26
|
if adapter_errors.any? { |error| exception.is_a?(error) }
|
20
|
-
Handler.instance.verify_primary(
|
27
|
+
Handler.instance.verify_primary(writing_role)
|
21
28
|
end
|
22
29
|
end
|
23
30
|
|
@@ -61,6 +68,9 @@ module RailsFailover
|
|
61
68
|
env[WRITING_ROLE_HEADER] = writing_role
|
62
69
|
@app.call(env)
|
63
70
|
end
|
71
|
+
rescue => e
|
72
|
+
Interceptor.verify_primary(e, writing_role) if writing_role
|
73
|
+
raise e
|
64
74
|
end
|
65
75
|
|
66
76
|
private
|
@@ -7,13 +7,15 @@ module RailsFailover
|
|
7
7
|
class Connector < ::Redis::Client::Connector
|
8
8
|
def initialize(options)
|
9
9
|
orignal_driver = options[:driver]
|
10
|
+
options[:primary_host] = options[:host]
|
11
|
+
options[:primary_port] = options[:port]
|
10
12
|
|
11
13
|
options[:driver] = Class.new(options[:driver]) do
|
12
14
|
def self.connect(options)
|
13
|
-
|
14
|
-
|
15
|
+
is_primary = (options[:host] == options[:primary_host]) &&
|
16
|
+
(options[:port] == options[:primary_port])
|
15
17
|
super(options).tap do |conn|
|
16
|
-
conn.rails_failover_role =
|
18
|
+
conn.rails_failover_role = is_primary ? PRIMARY : REPLICA
|
17
19
|
end
|
18
20
|
rescue ::Redis::TimeoutError,
|
19
21
|
SocketError,
|
@@ -26,7 +28,7 @@ module RailsFailover
|
|
26
28
|
Errno::ETIMEDOUT,
|
27
29
|
Errno::EINVAL => e
|
28
30
|
|
29
|
-
Handler.instance.verify_primary(options)
|
31
|
+
Handler.instance.verify_primary(options) if is_primary
|
30
32
|
raise e
|
31
33
|
end
|
32
34
|
|
@@ -55,6 +57,10 @@ module RailsFailover
|
|
55
57
|
|
56
58
|
def check(client)
|
57
59
|
Handler.instance.register_client(client)
|
60
|
+
expected_role = Handler.instance.primary_down?(@options) ? REPLICA : PRIMARY
|
61
|
+
if client.connection.rails_failover_role != expected_role
|
62
|
+
raise ::Redis::CannotConnectError, "Opened with unexpected failover role"
|
63
|
+
end
|
58
64
|
end
|
59
65
|
|
60
66
|
def on_disconnect(client)
|
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require 'monitor'
|
4
4
|
require 'singleton'
|
5
|
+
require 'concurrent'
|
5
6
|
|
6
7
|
module RailsFailover
|
7
8
|
class Redis
|
@@ -16,41 +17,51 @@ module RailsFailover
|
|
16
17
|
SOFT_DISCONNECT_POLL_SECONDS = 0.05
|
17
18
|
|
18
19
|
def initialize
|
19
|
-
@primaries_down =
|
20
|
-
@clients =
|
21
|
-
@ancestor_pid = Process.pid
|
20
|
+
@primaries_down = Concurrent::Map.new
|
21
|
+
@clients = Concurrent::Map.new
|
22
22
|
|
23
23
|
super() # Monitor#initialize
|
24
24
|
end
|
25
25
|
|
26
26
|
def verify_primary(options)
|
27
|
+
primary_down(options)
|
28
|
+
|
27
29
|
mon_synchronize do
|
28
|
-
|
29
|
-
|
30
|
+
return if @thread&.alive?
|
31
|
+
logger&.warn "Failover for Redis has been initiated"
|
32
|
+
@thread = Thread.new { loop_until_all_up }
|
30
33
|
end
|
31
34
|
end
|
32
35
|
|
33
|
-
def
|
34
|
-
|
36
|
+
def register_client(client)
|
37
|
+
id = client.options[:id]
|
38
|
+
clients_for_id(id).put_if_absent(client, true)
|
39
|
+
end
|
35
40
|
|
36
|
-
|
41
|
+
def deregister_client(client)
|
42
|
+
id = client.options[:id]
|
43
|
+
clients_for_id(id).delete(client)
|
44
|
+
end
|
37
45
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
try_fallback_to_primary
|
46
|
+
def primary_down?(options)
|
47
|
+
primaries_down[options[:id]]
|
48
|
+
end
|
42
49
|
|
43
|
-
|
44
|
-
|
45
|
-
break
|
46
|
-
end
|
47
|
-
end
|
48
|
-
end
|
50
|
+
def primaries_down_count
|
51
|
+
primaries_down.size
|
49
52
|
end
|
50
53
|
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
+
private
|
55
|
+
|
56
|
+
def loop_until_all_up
|
57
|
+
loop do
|
58
|
+
ensure_primary_clients_disconnected
|
59
|
+
try_fallback_to_primary
|
60
|
+
|
61
|
+
if all_primaries_up
|
62
|
+
logger&.warn "Fallback to primary for Redis has been completed."
|
63
|
+
break
|
64
|
+
end
|
54
65
|
end
|
55
66
|
end
|
56
67
|
|
@@ -60,7 +71,7 @@ module RailsFailover
|
|
60
71
|
|
61
72
|
active_primaries_keys = {}
|
62
73
|
|
63
|
-
|
74
|
+
primaries_down.each do |key, options|
|
64
75
|
info = nil
|
65
76
|
options = options.dup
|
66
77
|
|
@@ -87,103 +98,58 @@ module RailsFailover
|
|
87
98
|
end
|
88
99
|
end
|
89
100
|
|
90
|
-
def register_client(client)
|
91
|
-
key = client.options[:id]
|
92
|
-
|
93
|
-
mon_synchronize do
|
94
|
-
clients[key] ||= []
|
95
|
-
clients[key] << client
|
96
|
-
end
|
97
|
-
end
|
98
|
-
|
99
|
-
def deregister_client(client)
|
100
|
-
key = client.options[:id]
|
101
|
-
|
102
|
-
mon_synchronize do
|
103
|
-
if clients[key]
|
104
|
-
clients[key].delete(client)
|
105
|
-
|
106
|
-
if clients[key].empty?
|
107
|
-
clients.delete(key)
|
108
|
-
end
|
109
|
-
end
|
110
|
-
end
|
111
|
-
end
|
112
|
-
|
113
|
-
def primary_down?(options)
|
114
|
-
mon_synchronize do
|
115
|
-
primaries_down[options[:id]]
|
116
|
-
end
|
117
|
-
end
|
118
|
-
|
119
|
-
def primaries_down_count
|
120
|
-
mon_synchronize do
|
121
|
-
primaries_down.count
|
122
|
-
end
|
123
|
-
end
|
124
|
-
|
125
|
-
private
|
126
|
-
|
127
101
|
def all_primaries_up
|
128
|
-
|
102
|
+
primaries_down.empty?
|
129
103
|
end
|
130
104
|
|
131
105
|
def primary_up(options)
|
132
|
-
already_up =
|
133
|
-
!primaries_down.delete(options[:id])
|
134
|
-
end
|
106
|
+
already_up = !primaries_down.delete(options[:id])
|
135
107
|
RailsFailover::Redis.on_fallback_callback!(options[:id]) if !already_up
|
136
108
|
end
|
137
109
|
|
138
110
|
def primary_down(options)
|
139
|
-
already_down =
|
140
|
-
mon_synchronize do
|
141
|
-
already_down = !!primaries_down[options[:id]]
|
142
|
-
primaries_down[options[:id]] = options.dup
|
143
|
-
end
|
111
|
+
already_down = primaries_down.put_if_absent(options[:id], options.dup)
|
144
112
|
RailsFailover::Redis.on_failover_callback!(options[:id]) if !already_down
|
145
113
|
end
|
146
114
|
|
147
|
-
def
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
@clients[process_pid] = {}
|
154
|
-
|
155
|
-
if process_pid != @ancestor_pid
|
156
|
-
@clients.delete(@ancestor_pid)
|
157
|
-
end
|
158
|
-
end
|
115
|
+
def primaries_down
|
116
|
+
ancestor_pids = nil
|
117
|
+
value = @primaries_down.compute_if_absent(Process.pid) do
|
118
|
+
ancestor_pids = @primaries_down.keys
|
119
|
+
@primaries_down.values.first || Concurrent::Map.new
|
120
|
+
end
|
159
121
|
|
160
|
-
|
122
|
+
ancestor_pids&.each do |pid|
|
123
|
+
@primaries_down.delete(pid)&.each { |id, options| verify_primary(options) }
|
161
124
|
end
|
125
|
+
|
126
|
+
value
|
162
127
|
end
|
163
128
|
|
164
|
-
def
|
165
|
-
|
166
|
-
|
129
|
+
def clients_for_id(id)
|
130
|
+
clients.compute_if_absent(id) { Concurrent::Map.new }
|
131
|
+
end
|
167
132
|
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
end
|
133
|
+
def clients
|
134
|
+
ancestor_pids = nil
|
135
|
+
clients_for_pid = @clients.compute_if_absent(Process.pid) do
|
136
|
+
ancestor_pids = @clients.keys
|
137
|
+
Concurrent::Map.new
|
138
|
+
end
|
139
|
+
ancestor_pids&.each { |k| @clients.delete(k) }
|
140
|
+
clients_for_pid
|
141
|
+
end
|
178
142
|
|
179
|
-
|
143
|
+
def ensure_primary_clients_disconnected
|
144
|
+
primaries_down.each do |key, options|
|
145
|
+
disconnect_clients(options, RailsFailover::Redis::PRIMARY)
|
180
146
|
end
|
181
147
|
end
|
182
148
|
|
183
149
|
def disconnect_clients(options, role)
|
184
|
-
|
150
|
+
id = options[:id]
|
185
151
|
|
186
|
-
matched_clients =
|
152
|
+
matched_clients = clients_for_id(id)&.keys
|
187
153
|
&.filter { |c| c.connection.rails_failover_role == role }
|
188
154
|
&.to_set
|
189
155
|
|
data/makefile
CHANGED
@@ -9,7 +9,7 @@ test_active_record:
|
|
9
9
|
@ACTIVE_RECORD=1 bundle exec rspec --tag type:active_record ${RSPEC_PATH}
|
10
10
|
|
11
11
|
setup_dummy_rails_server:
|
12
|
-
@cd spec/support/dummy_app && bundle install --quiet
|
12
|
+
@cd spec/support/dummy_app && bundle install --quiet && yarn install && RAILS_ENV=production $(BUNDLER_BIN) exec rails db:create db:migrate db:seed
|
13
13
|
|
14
14
|
start_dummy_rails_server:
|
15
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
|
data/rails_failover.gemspec
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rails_failover
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.6.
|
4
|
+
version: 0.6.5
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Alan Tan
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-12-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -38,7 +38,21 @@ dependencies:
|
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '6.0'
|
41
|
-
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: concurrent-ruby
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
description:
|
42
56
|
email:
|
43
57
|
- tgx@discourse.org
|
44
58
|
executables: []
|
@@ -73,11 +87,11 @@ files:
|
|
73
87
|
- postgresql.mk
|
74
88
|
- rails_failover.gemspec
|
75
89
|
- redis.mk
|
76
|
-
homepage:
|
90
|
+
homepage:
|
77
91
|
licenses:
|
78
92
|
- MIT
|
79
93
|
metadata: {}
|
80
|
-
post_install_message:
|
94
|
+
post_install_message:
|
81
95
|
rdoc_options: []
|
82
96
|
require_paths:
|
83
97
|
- lib
|
@@ -92,8 +106,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
92
106
|
- !ruby/object:Gem::Version
|
93
107
|
version: '0'
|
94
108
|
requirements: []
|
95
|
-
rubygems_version: 3.
|
96
|
-
signing_key:
|
109
|
+
rubygems_version: 3.1.4
|
110
|
+
signing_key:
|
97
111
|
specification_version: 4
|
98
112
|
summary: Failover for ActiveRecord and Redis
|
99
113
|
test_files: []
|