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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dea60f6f50e09e45029bc4f73d89eadefd569935d6855c3542f82aa3e1a01b3f
4
- data.tar.gz: 5ccd925be0977242e1f139481b59a3feb3e0a4a9dbf19f8cf21064466fcdbcb4
3
+ metadata.gz: 45afa97c24a3c8f4fac5c74a52eabbf0d06b9f7ad037a76b18bee05875b4f374
4
+ data.tar.gz: e57ffc9182a9b2f4145c04d4f921bcd79aee3edfec5dedacac2fb6f23d39dc9d
5
5
  SHA512:
6
- metadata.gz: d39ff2d6eb505dff92151c884bb7ec3aa608d4bd29c79eea2a0e2e965b8eec6ab787f930128e0d3ffcbbfb1eb2cedccf1a8fa30417eff4d3a2cfea40d536d0a7
7
- data.tar.gz: 2b36aa85cff837f80b0876ce7b2af3017c935601ec5c5329f2bab26f5ccb395d8dd50111bee7dbf3ad2bb6e15cb22f2aa18f9fff89b3a101689f1d5036371f7e
6
+ metadata.gz: 0ee4ae4e79e17881a49f77711b5f13f301977d67cd177f8d16296cff93c0aea63b9151f235acfde87afaae14f6215840fbb3efb6024a98b59c14454b88705356
7
+ data.tar.gz: 48d815e482d5b6b92316431bda654709f3049647292c4a4d9b8a6a075086be2e23b12cceed898d07e10f729bc85217aa395521a49ffd1c4baad63927ed938963
@@ -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
@@ -1,8 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rails_failover (0.6.0)
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.9.0)
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
- @thread = Thread.new do
29
- loop do
30
- initiate_fallback_to_primary
40
+ def loop_until_all_up
41
+ loop do
42
+ initiate_fallback_to_primary
31
43
 
32
- if all_primaries_up
33
- logger.warn "Fallback to primary for ActiveRecord has been completed."
34
- break
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
- mon_synchronize do
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 = false
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 = mon_synchronize do
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
- process_pid = Process.pid
114
- return @primaries_down[process_pid] if @primaries_down[process_pid]
115
-
116
- mon_synchronize do
117
- if !@primaries_down[process_pid]
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
- @primaries_down[process_pid]
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(request.env[Middleware::WRITING_ROLE_HEADER])
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
- is_failover_replica = (options[:host] == options[:replica_host]) &&
14
- (options[:port] == options[:replica_port])
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 = is_failover_replica ? REPLICA : PRIMARY
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
- primary_down(options)
29
- ensure_failover_thread_running
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 ensure_failover_thread_running
34
- return if @thread&.alive?
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
- logger&.warn "Failover for Redis has been initiated"
41
+ def deregister_client(client)
42
+ id = client.options[:id]
43
+ clients_for_id(id).delete(client)
44
+ end
37
45
 
38
- @thread = Thread.new do
39
- loop do
40
- ensure_primary_clients_disconnected
41
- try_fallback_to_primary
46
+ def primary_down?(options)
47
+ primaries_down[options[:id]]
48
+ end
42
49
 
43
- if all_primaries_up
44
- logger&.warn "Fallback to primary for Redis has been completed."
45
- break
46
- end
47
- end
48
- end
50
+ def primaries_down_count
51
+ primaries_down.size
49
52
  end
50
53
 
51
- def ensure_primary_clients_disconnected
52
- mon_synchronize { primaries_down.dup }.each do |key, options|
53
- disconnect_clients(options, RailsFailover::Redis::PRIMARY)
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
- mon_synchronize { primaries_down.dup }.each do |key, options|
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
- mon_synchronize { primaries_down.empty? }
102
+ primaries_down.empty?
129
103
  end
130
104
 
131
105
  def primary_up(options)
132
- already_up = mon_synchronize do
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 = false
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 clients
148
- process_pid = Process.pid
149
- return @clients[process_pid] if @clients[process_pid]
150
-
151
- mon_synchronize do
152
- if !@clients[process_pid]
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
- @clients[process_pid]
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 primaries_down
165
- process_pid = Process.pid
166
- return @primaries_down[process_pid] if @primaries_down[process_pid]
129
+ def clients_for_id(id)
130
+ clients.compute_if_absent(id) { Concurrent::Map.new }
131
+ end
167
132
 
168
- mon_synchronize do
169
- if !@primaries_down[process_pid]
170
- @primaries_down[process_pid] = @primaries_down[@ancestor_pid] || {}
171
-
172
- if process_pid != @ancestor_pid
173
- @primaries_down.delete(@ancestor_pid)&.each do |id, options|
174
- verify_primary(options)
175
- end
176
- end
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
- @primaries_down[process_pid]
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
- key = options[:id]
150
+ id = options[:id]
185
151
 
186
- matched_clients = mon_synchronize { clients[key].dup }
152
+ matched_clients = clients_for_id(id)&.keys
187
153
  &.filter { |c| c.connection.rails_failover_role == role }
188
154
  &.to_set
189
155
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsFailover
4
- VERSION = "0.6.0"
4
+ VERSION = "0.6.5"
5
5
  end
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 --without test --without development && yarn install && RAILS_ENV=production $(BUNDLER_BIN) exec rails db:create db:migrate db:seed
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
@@ -24,4 +24,5 @@ Gem::Specification.new do |spec|
24
24
  ["activerecord", "railties"].each do |gem_name|
25
25
  spec.add_dependency gem_name, "~> 6.0"
26
26
  end
27
+ spec.add_dependency "concurrent-ruby"
27
28
  end
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.0
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-09 00:00:00.000000000 Z
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
- description:
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.0.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: []