rails_failover 0.5.7 → 0.5.8

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6711544c6ae90ab17b509fc3cb97e7b5cc4fd5579daee706dfcc9abb03141a22
4
- data.tar.gz: 4bea899af7859b72f88a5e9bd185f72fbca2b968b4a8ed0d73dbc6e41be5acce
3
+ metadata.gz: 1d65e714f38f6431486cd9c2ae8d86e02226f0b1292dca145f056aba3888b05f
4
+ data.tar.gz: 1c3c4b620ebee9d56ec76523d927149e47cfbc870546483042fe4731e1e7f310
5
5
  SHA512:
6
- metadata.gz: 9d58e8aeec4c77b22e8a44d80667b8af553b10323d826fa16da493ab71326e3f0fb4c8f8b4f00fa89f2e53a357642b6c84d138590561c2d389a03de83cac8510
7
- data.tar.gz: a6de657f916e8ced227f21c06ab36f168ca8c17adfde474e80526d9a1ab145ab2b00500c93775b22bd6efa35e604fb0c8f9d9b91256caf567a80cb9599c9e270
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}}
@@ -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.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rails_failover (0.5.7)
4
+ rails_failover (0.5.8)
5
5
  activerecord (~> 6.0)
6
6
  railties (~> 6.0)
7
7
 
@@ -13,6 +13,9 @@ require_relative 'redis/connector'
13
13
 
14
14
  module RailsFailover
15
15
  class Redis
16
+ PRIMARY = :primary
17
+ REPLICA = :replica
18
+
16
19
  def self.logger=(logger)
17
20
  @logger = logger
18
21
  end
@@ -10,7 +10,11 @@ module RailsFailover
10
10
 
11
11
  options[:driver] = Class.new(options[:driver]) do
12
12
  def self.connect(options)
13
- super(options)
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.delete(:replica_host)
58
- opts[:port] = opts.delete(:replica_port)
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
- disconnect_clients(options)
28
-
29
- return if @thread&.alive?
29
+ ensure_failover_thread_running
30
+ end
31
+ end
30
32
 
31
- logger&.warn "Failover for Redis has been initiated"
33
+ def ensure_failover_thread_running
34
+ return if @thread&.alive?
32
35
 
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
36
+ logger&.warn "Failover for Redis has been initiated"
38
37
 
39
- @thread = Thread.new do
40
- loop do
41
- thread = Thread.new { initiate_fallback_to_primary }
42
- thread.join
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
- if all_primaries_up
45
- logger&.warn "Fallback to primary for Redis has been completed."
44
+ @thread = Thread.new do
45
+ loop do
46
+ ensure_primary_clients_disconnected
47
+ try_fallback_to_primary
46
48
 
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
49
+ if all_primaries_up
50
+ logger&.warn "Fallback to primary for Redis has been completed."
52
51
 
53
- break
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 initiate_fallback_to_primary
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 do
180
- if to_disconnect = clients[key].dup
181
- # Don't disconnect connections abruptly since it may lead to unexepcted
182
- # errors. Is there a better way to do this without having to monkey patch
183
- # the redis-rb gem heavily?
184
- ObjectSpace.each_object(::Redis).each do |redis|
185
- to_disconnect.each do |c|
186
- if redis._client == c
187
- redis.synchronize { |_client| _client.disconnect }
188
- end
189
- end
190
- end
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsFailover
4
- VERSION = "0.5.7"
4
+ VERSION = "0.5.8"
5
5
  end
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.7
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-09-16 00:00:00.000000000 Z
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.1.2
95
+ rubygems_version: 3.0.3
96
96
  signing_key:
97
97
  specification_version: 4
98
98
  summary: Failover for ActiveRecord and Redis
@@ -1,6 +0,0 @@
1
- ---
2
- language: ruby
3
- cache: bundler
4
- rvm:
5
- - 2.6.2
6
- before_install: gem install bundler -v 2.1.4