rails_failover 0.5.7 → 0.5.8
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/.github/workflows/ci.yml +81 -0
- data/CHANGELOG.md +9 -0
- data/Gemfile.lock +1 -1
- data/lib/rails_failover/redis.rb +3 -0
- data/lib/rails_failover/redis/connector.rb +14 -3
- data/lib/rails_failover/redis/handler.rb +70 -36
- data/lib/rails_failover/version.rb +1 -1
- data/makefile +1 -1
- metadata +4 -4
- data/.travis.yml +0 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1d65e714f38f6431486cd9c2ae8d86e02226f0b1292dca145f056aba3888b05f
|
4
|
+
data.tar.gz: 1c3c4b620ebee9d56ec76523d927149e47cfbc870546483042fe4731e1e7f310
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 92751dce5ea5a2d1dcb905f01c9c33678e1cd76999e8d0ee1d59610fd3bcad6f5938a92013a8e2b11f7667a0c50d6b7ac5c8081d113a12f58a429d986e53353a
|
7
|
+
data.tar.gz: b72c80bc12e2852e091d5ad267674cb9238ec7aaa63e3b67304945d0ccc00f55c3f2594a1655d3235ee8b69b362bef00c5793eda5a339fe774a0583f829600ca
|
@@ -0,0 +1,81 @@
|
|
1
|
+
name: CI
|
2
|
+
|
3
|
+
on:
|
4
|
+
pull_request:
|
5
|
+
push:
|
6
|
+
branches:
|
7
|
+
- master
|
8
|
+
tags:
|
9
|
+
- v*
|
10
|
+
|
11
|
+
jobs:
|
12
|
+
build:
|
13
|
+
runs-on: ubuntu-latest
|
14
|
+
|
15
|
+
env:
|
16
|
+
BUILD_TYPE: ${{ matrix.build_types }}
|
17
|
+
|
18
|
+
strategy:
|
19
|
+
fail-fast: false
|
20
|
+
matrix:
|
21
|
+
ruby:
|
22
|
+
- 2.6
|
23
|
+
- 2.7
|
24
|
+
build_types: ["LINT", "REDIS", "ACTIVERECORD"]
|
25
|
+
exclude:
|
26
|
+
- ruby: 2.6
|
27
|
+
build_types: "LINT"
|
28
|
+
|
29
|
+
steps:
|
30
|
+
- uses: actions/checkout@v1
|
31
|
+
|
32
|
+
- name: Setup ruby
|
33
|
+
uses: actions/setup-ruby@v1
|
34
|
+
with:
|
35
|
+
ruby-version: ${{ matrix.ruby }}
|
36
|
+
architecture: 'x64'
|
37
|
+
|
38
|
+
- name: Setup bundler
|
39
|
+
run: gem install bundler
|
40
|
+
|
41
|
+
- name: Setup gems
|
42
|
+
run: bundle install
|
43
|
+
|
44
|
+
- name: Rubocop
|
45
|
+
run: bundle exec rubocop
|
46
|
+
if: env.BUILD_TYPE == 'LINT'
|
47
|
+
|
48
|
+
- name: Setup redis
|
49
|
+
run: sudo apt-get install redis-server
|
50
|
+
if: env.BUILD_TYPE == 'REDIS'
|
51
|
+
|
52
|
+
- name: Redis specs
|
53
|
+
run: bin/rspec redis
|
54
|
+
if: env.BUILD_TYPE == 'REDIS'
|
55
|
+
|
56
|
+
- name: Setup test app gems
|
57
|
+
run: cd spec/support/dummy_app && bundle install
|
58
|
+
if: env.BUILD_TYPE == 'ACTIVERECORD'
|
59
|
+
|
60
|
+
- name: Setup postgres
|
61
|
+
run: |
|
62
|
+
make setup_pg
|
63
|
+
make start_pg
|
64
|
+
if: env.BUILD_TYPE == 'ACTIVERECORD'
|
65
|
+
|
66
|
+
- name: ActiveRecord specs
|
67
|
+
run: bin/rspec active_record
|
68
|
+
if: env.BUILD_TYPE == 'ACTIVERECORD'
|
69
|
+
|
70
|
+
publish:
|
71
|
+
if: contains(github.ref, 'refs/tags/v')
|
72
|
+
needs: build
|
73
|
+
runs-on: ubuntu-latest
|
74
|
+
|
75
|
+
steps:
|
76
|
+
- uses: actions/checkout@v2
|
77
|
+
|
78
|
+
- name: Release Gem
|
79
|
+
uses: CvX/publish-rubygems-action@master
|
80
|
+
env:
|
81
|
+
RUBYGEMS_API_KEY: ${{secrets.RUBYGEMS_API_KEY}}
|
data/CHANGELOG.md
CHANGED
@@ -6,6 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
6
6
|
|
7
7
|
## [Unreleased]
|
8
8
|
|
9
|
+
## [0.5.8] - 2020-11-05
|
10
|
+
|
11
|
+
- FIX: Handle concurrency issues during redis disconnection (#10)
|
12
|
+
|
13
|
+
This handles concurrency issues which can happen during redis failover/fallback:
|
14
|
+
- Previously, 'subscribed' redis clients were skipped during the disconnect process. This is resolved by directly accessing the original_client from the ::Redis instance
|
15
|
+
- Trying to acquire the mutex on a subscribed redis client is impossible, so the close operation would never complete. Now we send the shutdown() signal to the thread, then allow up to 1 second for the mutex to be released before we close the socket
|
16
|
+
- Failover is almost always triggered inside a redis client mutex. Failover then has its own mutex, within which we attempted to acquire mutexes for all redis clients. This logic causes a deadlock when multiple clients failover simultaneously. Now, all disconnection is performed by the Redis::Handler failover thread, outside of any other mutexes. To make this safe, the primary/replica state is stored in the connection driver, and disconnect_clients is updated to specifically target primary/replica connections.
|
17
|
+
|
9
18
|
## [0.5.7] - 2020-09-16
|
10
19
|
|
11
20
|
- FIX: Avoid disconnecting Redis connections abruptly.
|
data/Gemfile.lock
CHANGED
data/lib/rails_failover/redis.rb
CHANGED
@@ -10,7 +10,11 @@ module RailsFailover
|
|
10
10
|
|
11
11
|
options[:driver] = Class.new(options[:driver]) do
|
12
12
|
def self.connect(options)
|
13
|
-
|
13
|
+
is_failover_replica = (options[:host] == options[:replica_host]) &&
|
14
|
+
(options[:port] == options[:replica_port])
|
15
|
+
super(options).tap do |conn|
|
16
|
+
conn.rails_failover_role = is_failover_replica ? REPLICA : PRIMARY
|
17
|
+
end
|
14
18
|
rescue ::Redis::TimeoutError,
|
15
19
|
SocketError,
|
16
20
|
Errno::EADDRNOTAVAIL,
|
@@ -25,6 +29,13 @@ module RailsFailover
|
|
25
29
|
Handler.instance.verify_primary(options)
|
26
30
|
raise e
|
27
31
|
end
|
32
|
+
|
33
|
+
attr_accessor :rails_failover_role
|
34
|
+
|
35
|
+
def shutdown_socket
|
36
|
+
@sock&.shutdown
|
37
|
+
rescue Errno::ENOTCONN
|
38
|
+
end
|
28
39
|
end
|
29
40
|
|
30
41
|
options[:original_driver] = orignal_driver
|
@@ -54,8 +65,8 @@ module RailsFailover
|
|
54
65
|
|
55
66
|
def replica_options(options)
|
56
67
|
opts = options.dup
|
57
|
-
opts[:host] = opts
|
58
|
-
opts[:port] = opts
|
68
|
+
opts[:host] = opts[:replica_host]
|
69
|
+
opts[:port] = opts[:replica_port]
|
59
70
|
opts
|
60
71
|
end
|
61
72
|
end
|
@@ -12,6 +12,8 @@ module RailsFailover
|
|
12
12
|
PRIMARY_ROLE_STATUS = "role:master"
|
13
13
|
PRIMARY_LOADED_STATUS = "loading:0"
|
14
14
|
VERIFY_FREQUENCY_BUFFER_PRECENT = 20
|
15
|
+
SOFT_DISCONNECT_TIMEOUT_SECONDS = 1
|
16
|
+
SOFT_DISCONNECT_POLL_SECONDS = 0.05
|
15
17
|
|
16
18
|
def initialize
|
17
19
|
@primaries_down = {}
|
@@ -24,40 +26,47 @@ module RailsFailover
|
|
24
26
|
def verify_primary(options)
|
25
27
|
mon_synchronize do
|
26
28
|
primary_down(options)
|
27
|
-
|
28
|
-
|
29
|
-
|
29
|
+
ensure_failover_thread_running
|
30
|
+
end
|
31
|
+
end
|
30
32
|
|
31
|
-
|
33
|
+
def ensure_failover_thread_running
|
34
|
+
return if @thread&.alive?
|
32
35
|
|
33
|
-
|
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
|
36
|
+
logger&.warn "Failover for Redis has been initiated"
|
38
37
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
38
|
+
begin
|
39
|
+
RailsFailover::Redis.on_failover_callback&.call
|
40
|
+
rescue => e
|
41
|
+
logger&.warn("RailsFailover::Redis.on_failover_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
|
42
|
+
end
|
43
43
|
|
44
|
-
|
45
|
-
|
44
|
+
@thread = Thread.new do
|
45
|
+
loop do
|
46
|
+
ensure_primary_clients_disconnected
|
47
|
+
try_fallback_to_primary
|
46
48
|
|
47
|
-
|
48
|
-
|
49
|
-
rescue => e
|
50
|
-
logger&.warn("RailsFailover::Redis.on_fallback_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
|
51
|
-
end
|
49
|
+
if all_primaries_up
|
50
|
+
logger&.warn "Fallback to primary for Redis has been completed."
|
52
51
|
|
53
|
-
|
52
|
+
begin
|
53
|
+
RailsFailover::Redis.on_fallback_callback&.call
|
54
|
+
rescue => e
|
55
|
+
logger&.warn("RailsFailover::Redis.on_fallback_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
|
54
56
|
end
|
57
|
+
break
|
55
58
|
end
|
56
59
|
end
|
57
60
|
end
|
58
61
|
end
|
59
62
|
|
60
|
-
def
|
63
|
+
def ensure_primary_clients_disconnected
|
64
|
+
mon_synchronize { primaries_down.dup }.each do |key, options|
|
65
|
+
disconnect_clients(options, RailsFailover::Redis::PRIMARY)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def try_fallback_to_primary
|
61
70
|
frequency = RailsFailover::Redis.verify_primary_frequency_seconds
|
62
71
|
sleep(frequency * ((rand(VERIFY_FREQUENCY_BUFFER_PRECENT) + 100) / 100.0))
|
63
72
|
|
@@ -86,7 +95,7 @@ module RailsFailover
|
|
86
95
|
|
87
96
|
active_primaries_keys.each do |key, options|
|
88
97
|
primary_up(options)
|
89
|
-
disconnect_clients(options)
|
98
|
+
disconnect_clients(options, RailsFailover::Redis::REPLICA)
|
90
99
|
end
|
91
100
|
end
|
92
101
|
|
@@ -173,23 +182,48 @@ module RailsFailover
|
|
173
182
|
end
|
174
183
|
end
|
175
184
|
|
176
|
-
def disconnect_clients(options)
|
185
|
+
def disconnect_clients(options, role)
|
177
186
|
key = options[:id]
|
178
187
|
|
179
|
-
mon_synchronize
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
188
|
+
matched_clients = mon_synchronize { clients[key].dup }
|
189
|
+
&.filter { |c| c.connection.rails_failover_role == role }
|
190
|
+
&.to_set
|
191
|
+
|
192
|
+
return if matched_clients.nil? || matched_clients.empty?
|
193
|
+
|
194
|
+
# This is not ideal, but the mutex we need is contained
|
195
|
+
# in the ::Redis instance, not the Redis::Client
|
196
|
+
ObjectSpace.each_object(::Redis) do |redis|
|
197
|
+
# When subscribed, Redis#_client is not a Redis::Client
|
198
|
+
# Instance variable is the only reliable way
|
199
|
+
client = redis.instance_variable_get(:@original_client)
|
200
|
+
next if !matched_clients.include?(client)
|
201
|
+
soft_disconnect(redis, client, role)
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
# Calling .disconnect can cause a running subscribe() to block forever
|
206
|
+
# Therefore try to acquire the lock
|
207
|
+
def soft_disconnect(redis, client, role)
|
208
|
+
has_lock = redis.mon_try_enter
|
209
|
+
|
210
|
+
if !has_lock
|
211
|
+
client.connection.shutdown_socket
|
212
|
+
|
213
|
+
waiting_since = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
214
|
+
loop do # Keep trying
|
215
|
+
break if has_lock = redis.mon_try_enter
|
216
|
+
break if !client.connection.connected? # Disconnected by other thread
|
217
|
+
break if client.connection.rails_failover_role != role # Reconnected by other thread
|
218
|
+
time_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
219
|
+
break if time_now > waiting_since + SOFT_DISCONNECT_TIMEOUT_SECONDS
|
220
|
+
sleep SOFT_DISCONNECT_POLL_SECONDS
|
191
221
|
end
|
192
222
|
end
|
223
|
+
|
224
|
+
client.disconnect if client.connection&.rails_failover_role == role
|
225
|
+
ensure
|
226
|
+
redis.mon_exit if has_lock
|
193
227
|
end
|
194
228
|
|
195
229
|
def logger
|
data/makefile
CHANGED
@@ -18,4 +18,4 @@ stop_dummy_rails_server:
|
|
18
18
|
@kill -TERM $(shell cat spec/support/dummy_app/tmp/pids/unicorn.pid)
|
19
19
|
|
20
20
|
teardown_dummy_rails_server:
|
21
|
-
@cd spec/support/dummy_app && DISABLE_DATABASE_ENVIRONMENT_CHECK=1 RAILS_ENV=production $(BUNDLER_BIN) exec rails db:drop
|
21
|
+
@cd spec/support/dummy_app && (! (bundle check > /dev/null 2>&1) || DISABLE_DATABASE_ENVIRONMENT_CHECK=1 RAILS_ENV=production $(BUNDLER_BIN) exec rails db:drop)
|
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.5.
|
4
|
+
version: 0.5.8
|
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-11-05 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -45,10 +45,10 @@ executables: []
|
|
45
45
|
extensions: []
|
46
46
|
extra_rdoc_files: []
|
47
47
|
files:
|
48
|
+
- ".github/workflows/ci.yml"
|
48
49
|
- ".gitignore"
|
49
50
|
- ".rspec"
|
50
51
|
- ".rubocop.yml"
|
51
|
-
- ".travis.yml"
|
52
52
|
- CHANGELOG.md
|
53
53
|
- CODE_OF_CONDUCT.md
|
54
54
|
- Gemfile
|
@@ -92,7 +92,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
92
92
|
- !ruby/object:Gem::Version
|
93
93
|
version: '0'
|
94
94
|
requirements: []
|
95
|
-
rubygems_version: 3.
|
95
|
+
rubygems_version: 3.0.3
|
96
96
|
signing_key:
|
97
97
|
specification_version: 4
|
98
98
|
summary: Failover for ActiveRecord and Redis
|