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 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