rails_failover 0.3.0 → 0.5.3

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: 307117d6a7083a17a2bf2799737f6f9be3afa77a58bace4f867bedf38fa05a5c
4
- data.tar.gz: 9d9d54a7a46785f259ba3f19aad402534de7b5305bd57993cb9c92fd14efad3b
3
+ metadata.gz: 31e5bf77f85686776c2cf344b2e145af8e99a984696102cbdf51b457d45f1668
4
+ data.tar.gz: aa6556a7bf399949066078bd463eeb9744db5e16a7152bf112a1ba023226b4bf
5
5
  SHA512:
6
- metadata.gz: 7c45a722a9d8bbe1d0ea1410630252c5485496a49b6affe5461615779102d25bfafbf0d990bce920d8e52f164ebe1b92e94a1ccd1787d526b98cba54fc039d45
7
- data.tar.gz: 3dcd9668ef0266b91c35cb3764533fab3624025a3d4dd6db86ce1adafb8168c9d72dde14bbd57fbcb73f7f0049941e6b077eaba519b70b3fa1b03eb92d730b64
6
+ metadata.gz: 46a47eab782fa173e3a18b8d0159798129f39739ffdc9e910f06ba698c6a78689df33120d67278b1b56d1dda0b83d25cbbcd4919ae2384eb932fbc33481f087f
7
+ data.tar.gz: aa69d48628da810cbefbd386a093cff557425d4e77f22985bcc7bbb75b25a522417b4d36a3ae9172832f880974614fd7056187b71e05049c605ec8d92e80de96
@@ -1,3 +1,7 @@
1
+ AllCops:
2
+ Exclude:
3
+ - spec/support/dummy_app/**/*
4
+
1
5
  inherit_gem:
2
6
  rubocop-discourse: default.yml
3
7
 
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+ All notable changes to this project will be documented in this file.
3
+
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.5.2] - 2020-06-23
10
+
11
+ ### Changed
12
+ - FIX: Only rescue from connection errors.q
data/Gemfile CHANGED
@@ -6,14 +6,9 @@ gemspec
6
6
 
7
7
  gem "rake", "~> 12.0"
8
8
  gem "rspec", "~> 3.0"
9
-
10
- group :development do
11
- gem 'rubocop-discourse'
12
- end
13
-
14
- group :development, :test do
15
- gem 'byebug'
16
- gem 'redis', '~> 4.1'
17
- gem 'pg', '~> 1.2'
18
- gem 'activerecord', '~> 6.0'
19
- end
9
+ gem 'rubocop-discourse'
10
+ gem 'byebug'
11
+ gem 'redis', '~> 4.1'
12
+ gem 'pg', '~> 1.2'
13
+ gem 'activerecord', '~> 6.0'
14
+ gem 'rack'
@@ -1,9 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rails_failover (0.3.0)
4
+ rails_failover (0.5.3)
5
5
  activerecord (~> 6.0)
6
- listen (~> 3.2)
7
6
  railties (~> 6.0)
8
7
 
9
8
  GEM
@@ -40,25 +39,21 @@ GEM
40
39
  crass (1.0.6)
41
40
  diff-lcs (1.3)
42
41
  erubi (1.9.0)
43
- ffi (1.12.2)
44
42
  i18n (1.8.2)
45
43
  concurrent-ruby (~> 1.0)
46
- listen (3.2.1)
47
- rb-fsevent (~> 0.10, >= 0.10.3)
48
- rb-inotify (~> 0.9, >= 0.9.10)
49
- loofah (2.5.0)
44
+ loofah (2.6.0)
50
45
  crass (~> 1.0.2)
51
46
  nokogiri (>= 1.5.9)
52
47
  method_source (1.0.0)
53
48
  mini_portile2 (2.4.0)
54
49
  minitest (5.14.1)
55
- nokogiri (1.10.9)
50
+ nokogiri (1.10.10)
56
51
  mini_portile2 (~> 2.4.0)
57
52
  parallel (1.19.1)
58
53
  parser (2.7.1.2)
59
54
  ast (~> 2.4.0)
60
55
  pg (1.2.3)
61
- rack (2.2.2)
56
+ rack (2.2.3)
62
57
  rack-test (1.1.0)
63
58
  rack (>= 1.0, < 3)
64
59
  rails-dom-testing (2.0.3)
@@ -74,9 +69,6 @@ GEM
74
69
  thor (>= 0.20.3, < 2.0)
75
70
  rainbow (3.0.0)
76
71
  rake (12.3.3)
77
- rb-fsevent (0.10.4)
78
- rb-inotify (0.10.1)
79
- ffi (~> 1.0)
80
72
  redis (4.1.4)
81
73
  rexml (3.2.4)
82
74
  rspec (3.9.0)
@@ -119,6 +111,7 @@ DEPENDENCIES
119
111
  activerecord (~> 6.0)
120
112
  byebug
121
113
  pg (~> 1.2)
114
+ rack
122
115
  rails_failover!
123
116
  rake (~> 12.0)
124
117
  redis (~> 4.1)
data/README.md CHANGED
@@ -1,13 +1,16 @@
1
1
  # RailsFailover
2
2
 
3
- * Automatic failover and recovery for simple master-replica Redis setup
3
+ Automatic failover and recovery for primary/replica setup for:
4
+
5
+ 1. Redis
6
+ 1. ActiveRecord (PostgreSQL/MySQL Adapters)
4
7
 
5
8
  ## Installation
6
9
 
7
10
  Add this line to your application's Gemfile:
8
11
 
9
12
  ```ruby
10
- gem 'rails_failover'
13
+ gem 'rails_failover', require: false
11
14
  ```
12
15
 
13
16
  And then execute:
@@ -36,21 +39,58 @@ production:
36
39
 
37
40
  The gem will automatically create an `ActiveRecord::ConnectionAdapters::ConnectionHandler` with the `ActiveRecord::Base.reading_role` as the `handler_key`.
38
41
 
42
+ #### Failover/Fallback Hooks
43
+
44
+ ```
45
+ RailsFailover::ActiveRecord.on_failover do
46
+ # Enable readonly mode
47
+ end
48
+
49
+ RailsFailover::ActiveRecord.on_fallback do
50
+ # Disable readonly mode
51
+ end
52
+ ```
53
+
54
+ #### Multiple connection handlers
55
+
56
+ Note: This API is unstable and is likely to changes when Rails 6.1 is released with sharding support.
57
+
58
+ ```
59
+ # config/database.yml
60
+
61
+ production:
62
+ primary:
63
+ host: <primary db server host>
64
+ port: <primary db server port>
65
+ replica_host: <replica db server host>
66
+ replica_port: <replica db server port>
67
+ second_database_writing:
68
+ host: <primary db server host>
69
+ port: <primary db server port>
70
+ replica_host: <replica db server host>
71
+ replica_port: <replica db server port>
72
+
73
+ # In your ActiveRecord base model or model.
74
+
75
+ connects_to database: { writing: :primary, second_database_writing: :second_database_writing
76
+ ```
77
+
39
78
  ### Redis
40
79
 
80
+ Add `require 'rails_failover/redis'` before creating a `Redis` instance.
81
+
41
82
  ```
42
83
  Redis.new(host: "127.0.0.1", port: 6379, replica_host: "127.0.0.1", replica_port: 6380, connector: RailsFailover::Redis::Connector))
43
84
  ```
44
85
 
45
- Callbacks can be registered when the master connection is down and when it is up.
46
-
86
+ Callbacks can be registered when the primary connection is down and when it is up.
47
87
 
48
88
  ```
49
- RailsFailover::Redis.register_master_up_callback do
89
+ RailsFailover::Redis.on_failover_callback do
50
90
  # Switch site to read-only mode
51
91
  end
52
92
 
53
- RailsFailover::Redis.register_master_down_callback do
93
+ RailsFailover::Redis.on_fallback_callback do
54
94
  # Switch site out of read-only mode
55
95
  end
56
96
  ```
@@ -79,7 +119,6 @@ The ActiveRecord failover tests are run against a dummy Rails server. Run the fo
79
119
 
80
120
  Bug reports and pull requests are welcome on GitHub at https://github.com/discourse/rails_failover. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/discourse/rails_failover/blob/master/CODE_OF_CONDUCT.md).
81
121
 
82
-
83
122
  ## License
84
123
 
85
124
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_record'
4
- require_relative 'active_record/railtie'
4
+
5
+ if defined?(::Rails)
6
+ require_relative 'active_record/railtie'
7
+ end
8
+
5
9
  require_relative 'active_record/middleware'
6
10
  require_relative 'active_record/handler'
7
11
 
@@ -23,19 +27,36 @@ module RailsFailover
23
27
  @verify_primary_frequency_seconds || 5
24
28
  end
25
29
 
26
- def self.establish_reading_connection(connection_spec)
30
+ def self.establish_reading_connection(handler, connection_spec)
27
31
  config = connection_spec.config
28
32
 
29
33
  if config[:replica_host] && config[:replica_port]
30
34
  replica_config = config.dup
31
-
32
35
  replica_config[:host] = replica_config.delete(:replica_host)
33
36
  replica_config[:port] = replica_config.delete(:replica_port)
34
37
  replica_config[:replica] = true
35
-
36
- handler = ::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.reading_role]
37
38
  handler.establish_connection(replica_config)
38
39
  end
39
40
  end
41
+
42
+ def self.register_force_reading_role_callback(&block)
43
+ Middleware.force_reading_role_callback = block
44
+ end
45
+
46
+ def self.on_failover(&block)
47
+ @on_failover_callback = block
48
+ end
49
+
50
+ def self.on_failover_callback
51
+ @on_failover_callback
52
+ end
53
+
54
+ def self.on_fallback(&block)
55
+ @on_fallback_callback = block
56
+ end
57
+
58
+ def self.on_fallback_callback
59
+ @on_fallback_callback
60
+ end
40
61
  end
41
62
  end
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require 'singleton'
3
3
  require 'monitor'
4
- require 'listen'
5
- require 'fileutils'
6
4
 
7
5
  module RailsFailover
8
6
  module ActiveRecord
@@ -10,61 +8,45 @@ module RailsFailover
10
8
  include Singleton
11
9
  include MonitorMixin
12
10
 
13
- SEPERATOR = "__RAILS_FAILOVER__"
14
11
  VERIFY_FREQUENCY_BUFFER_PRECENT = 20
15
12
 
16
13
  def initialize
17
14
  @primaries_down = {}
18
15
  @ancestor_pid = Process.pid
19
16
 
20
- @dir = '/tmp/rails_failover'
21
- FileUtils.remove_dir(@dir) if Dir.exists?(@dir)
22
- FileUtils.mkdir_p(@dir)
23
-
24
- @listener = Listen.to(@dir) do |modified, added, removed|
25
- if added.length > 0
26
- added.each do |f|
27
- pid, handler_key = File.basename(f).split(SEPERATOR)
28
-
29
- if Process.pid != pid
30
- verify_primary(handler_key.to_sym, publish: false)
31
- end
32
- end
33
- end
34
-
35
- if removed.length > 0
36
- removed.each do |f|
37
- pid, handler_key = File.basename(f).split(SEPERATOR)
38
-
39
- if Process.pid != pid
40
- primary_up(handler_key.to_sym)
41
- end
42
- end
43
- end
44
- end
45
-
46
17
  super() # Monitor#initialize
47
18
  end
48
19
 
49
- def start_listener
50
- @listener.start
51
- end
52
-
53
- def verify_primary(handler_key, publish: true)
20
+ def verify_primary(handler_key)
54
21
  mon_synchronize do
55
22
  primary_down(handler_key)
56
- publish_primary_down(handler_key) if publish
57
- return if @thread&.alive? && @thread["pid"] == Process.pid
23
+ return if @thread&.alive?
24
+
25
+ logger.warn "Failover for ActiveRecord has been initiated"
26
+
27
+ begin
28
+ RailsFailover::ActiveRecord.on_failover_callback&.call
29
+ rescue => e
30
+ logger.warn("RailsFailover::ActiveRecord.on_failover_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
31
+ end
58
32
 
59
33
  @thread = Thread.new do
60
34
  loop do
61
35
  initiate_fallback_to_primary
62
- break if all_primaries_up
36
+
37
+ if all_primaries_up
38
+ logger.warn "Fallback to primary for ActiveRecord has been completed."
39
+
40
+ begin
41
+ RailsFailover::ActiveRecord.on_fallback_callback&.call
42
+ rescue => e
43
+ logger.warn("RailsFailover::ActiveRecord.on_fallback_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
44
+ end
45
+
46
+ break
47
+ end
63
48
  end
64
49
  end
65
-
66
- @thread["pid"] = Process.pid
67
- @thread
68
50
  end
69
51
  end
70
52
 
@@ -78,27 +60,26 @@ module RailsFailover
78
60
  connection_handler = ::ActiveRecord::Base.connection_handlers[handler_key]
79
61
  spec = connection_handler.retrieve_connection_pool(spec_name).spec
80
62
  config = spec.config
81
- logger.warn "#{Process.pid} Checking server for '#{handler_key} #{spec_name}'..."
63
+ logger.debug "#{Process.pid} Checking server for '#{handler_key} #{spec_name}'..."
82
64
  connection_active = false
83
65
 
84
66
  begin
85
67
  connection = ::ActiveRecord::Base.public_send(spec.adapter_method, config)
86
68
  connection_active = connection.active?
87
69
  rescue => e
88
- logger.warn "#{Process.pid} Connection to server for '#{handler_key} #{spec_name}' failed with '#{e.message}'"
70
+ logger.debug "#{Process.pid} Connection to server for '#{handler_key} #{spec_name}' failed with '#{e.message}'"
89
71
  ensure
90
72
  connection.disconnect! if connection
91
73
  end
92
74
 
93
75
  if connection_active
94
- logger.warn "#{Process.pid} Server for '#{handler_key} #{spec_name}' is active."
76
+ logger.debug "#{Process.pid} Server for '#{handler_key} #{spec_name}' is active."
95
77
  active_handler_keys << handler_key
96
78
  end
97
79
  end
98
80
 
99
81
  active_handler_keys.each do |handler_key|
100
82
  primary_up(handler_key)
101
- publish_primary_up(handler_key)
102
83
  end
103
84
  end
104
85
 
@@ -114,25 +95,12 @@ module RailsFailover
114
95
  end
115
96
  end
116
97
 
117
-
118
98
  def primary_down(handler_key)
119
99
  mon_synchronize do
120
100
  primaries_down[handler_key] = true
121
101
  end
122
102
  end
123
103
 
124
- def publish_primary_down(handler_key)
125
- FileUtils.touch("#{@dir}/#{Process.pid}#{SEPERATOR}#{handler_key}")
126
- end
127
-
128
- def publish_primary_up(handler_key)
129
- path = "#{@dir}/#{Process.pid}#{SEPERATOR}#{handler_key}"
130
-
131
- if File.exists?(path)
132
- FileUtils.rm("#{@dir}/#{Process.pid}#{SEPERATOR}#{handler_key}")
133
- end
134
- end
135
-
136
104
  def primary_up(handler_key)
137
105
  mon_synchronize do
138
106
  primaries_down.delete(handler_key)
@@ -165,7 +133,7 @@ module RailsFailover
165
133
  end
166
134
 
167
135
  def logger
168
- Rails.logger
136
+ ::Rails.logger
169
137
  end
170
138
  end
171
139
  end
@@ -2,39 +2,100 @@
2
2
 
3
3
  module RailsFailover
4
4
  module ActiveRecord
5
+ class Interceptor
6
+ def self.adapter_errors
7
+ @adapter_errors ||= begin
8
+ if defined?(::PG)
9
+ [::PG::UnableToSend, ::PG::ConnectionBad]
10
+ elsif defined?(::Mysql2)
11
+ [::Mysql2::Error::ConnectionError]
12
+ end
13
+ end
14
+ end
15
+
16
+ def self.handle(request, exception)
17
+ exception = resolve_cause(exception)
18
+
19
+ if adapter_errors.any? { |error| exception.is_a?(error) }
20
+ Handler.instance.verify_primary(request.env[Middleware::WRITING_ROLE_HEADER])
21
+ end
22
+ end
23
+
24
+ def self.resolve_cause(exception)
25
+ if exception.cause
26
+ resolve_cause(exception.cause)
27
+ else
28
+ exception
29
+ end
30
+ end
31
+ end
32
+
5
33
  class Middleware
34
+ class << self
35
+ attr_accessor :force_reading_role_callback
36
+ end
37
+
38
+ CURRENT_ROLE_HEADER = "rails_failover.role"
39
+ WRITING_ROLE_HEADER = "rails_failover.writing_role"
40
+
6
41
  def initialize(app)
7
42
  @app = app
8
43
  end
9
44
 
10
45
  def call(env)
11
- writing_role = ::ActiveRecord::Base.writing_role
46
+ current_role = ::ActiveRecord::Base.current_role || ::ActiveRecord::Base.writing_role
47
+ is_writing_role = current_role.to_s.end_with?(::ActiveRecord::Base.writing_role.to_s)
48
+ writing_role = resolve_writing_role(current_role, is_writing_role)
12
49
 
13
50
  role =
14
- if primary_down = Handler.instance.primary_down?(writing_role)
15
- ::ActiveRecord::Base.reading_role
51
+ if primary_down = self.class.force_reading_role_callback&.call(env) || Handler.instance.primary_down?(writing_role)
52
+ reading_role = resolve_reading_role(current_role, is_writing_role)
53
+ ensure_reading_connection_established!(writing_role: writing_role, reading_role: reading_role)
54
+ reading_role
16
55
  else
17
- ::ActiveRecord::Base.writing_role
56
+ writing_role
18
57
  end
19
58
 
20
59
  ::ActiveRecord::Base.connected_to(role: role) do
21
- env["rails_failover.role"] = role
60
+ env[CURRENT_ROLE_HEADER] = role
61
+ env[WRITING_ROLE_HEADER] = writing_role
22
62
  @app.call(env)
23
63
  end
24
- rescue Exception => e
25
- if (resolve_cause(e).is_a?(::PG::Error))
26
- Handler.instance.verify_primary(writing_role)
27
- raise
28
- end
29
64
  end
30
65
 
31
66
  private
32
67
 
33
- def resolve_cause(error)
34
- if error.cause
35
- resolve_cause(error.cause)
68
+ def ensure_reading_connection_established!(writing_role:, reading_role:)
69
+ ::ActiveRecord::Base.connection_handlers[reading_role] ||= begin
70
+ handler = ::ActiveRecord::ConnectionAdapters::ConnectionHandler.new
71
+
72
+ ::ActiveRecord::Base.connection_handlers[writing_role].connection_pools.each do |pool|
73
+ ::RailsFailover::ActiveRecord.establish_reading_connection(handler, pool.spec)
74
+ end
75
+
76
+ handler
77
+ end
78
+ end
79
+
80
+ def resolve_writing_role(current_role, is_writing_role)
81
+ if is_writing_role
82
+ current_role
83
+ else
84
+ current_role.to_s.sub(
85
+ /#{::ActiveRecord::Base.reading_role}$/,
86
+ ::ActiveRecord::Base.writing_role.to_s
87
+ ).to_sym
88
+ end
89
+ end
90
+
91
+ def resolve_reading_role(current_role, is_writing_role)
92
+ if is_writing_role
93
+ current_role.to_s.sub(
94
+ /#{::ActiveRecord::Base.writing_role}$/,
95
+ ::ActiveRecord::Base.reading_role.to_s
96
+ ).to_sym
36
97
  else
37
- error
98
+ current_role
38
99
  end
39
100
  end
40
101
  end
@@ -1,35 +1,57 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RailsFailover
2
4
  module ActiveRecord
3
5
  class Railtie < ::Rails::Railtie
4
- initializer "rails_failover.init", after: "active_record.initialize_database" do
5
- ::ActiveSupport.on_load(:active_record) do
6
- Handler.instance
6
+ initializer "rails_failover.init", after: "active_record.initialize_database" do |app|
7
+ config = ::ActiveRecord::Base.connection_config
8
+ app.config.active_record_rails_failover = false
7
9
 
8
- # We are doing this manually for now since we're awaiting Rails 6.1 to be released which will
9
- # have more stable ActiveRecord APIs for handling multiple databases with different roles.
10
- ::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.reading_role] =
11
- ::ActiveRecord::ConnectionAdapters::ConnectionHandler.new
10
+ if !!(config[:replica_host] && config[:replica_port])
11
+ app.config.active_record_rails_failover = true
12
12
 
13
- ::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.writing_role].connection_pools.each do |connection_pool|
14
- RailsFailover::ActiveRecord.establish_reading_connection(connection_pool.spec)
15
- end
13
+ ::ActiveSupport.on_load(:active_record) do
14
+ Handler.instance
15
+
16
+ # We are doing this manually for now since we're awaiting Rails 6.1 to be released which will
17
+ # have more stable ActiveRecord APIs for handling multiple databases with different roles.
18
+ ::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.reading_role] =
19
+ ::ActiveRecord::ConnectionAdapters::ConnectionHandler.new
16
20
 
17
- begin
18
- ::ActiveRecord::Base.connection
19
- rescue ::ActiveRecord::NoDatabaseError
20
- # Do nothing since database hasn't been created
21
- rescue ::PG::Error => e
22
- Handler.instance.verify_primary(::ActiveRecord::Base.writing_role)
23
- ::ActiveRecord::Base.connection_handler = ::ActiveRecord::Base.lookup_connection_handler(:reading)
21
+ ::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.writing_role].connection_pools.each do |connection_pool|
22
+ RailsFailover::ActiveRecord.establish_reading_connection(
23
+ ::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.reading_role],
24
+ connection_pool.spec
25
+ )
26
+ end
27
+
28
+ begin
29
+ ::ActiveRecord::Base.connection
30
+ rescue ::ActiveRecord::NoDatabaseError
31
+ # Do nothing since database hasn't been created
32
+ rescue ::PG::Error => e
33
+ Handler.instance.verify_primary(::ActiveRecord::Base.writing_role)
34
+ ::ActiveRecord::Base.connection_handler = ::ActiveRecord::Base.lookup_connection_handler(:reading)
35
+ end
24
36
  end
25
37
  end
26
38
  end
27
39
 
28
40
  initializer "rails_failover.insert_middleware" do |app|
29
- app.middleware.insert_after(
30
- ::ActionDispatch::ActionableExceptions,
31
- ::RailsFailover::ActiveRecord::Middleware
32
- )
41
+ if app.config.active_record_rails_failover
42
+ ActionDispatch::DebugExceptions.register_interceptor do |request, exception|
43
+ RailsFailover::ActiveRecord::Interceptor.handle(request, exception)
44
+ end
45
+
46
+ if !skip_middleware?(app.config)
47
+ app.middleware.unshift(::RailsFailover::ActiveRecord::Middleware)
48
+ end
49
+ end
50
+ end
51
+
52
+ def skip_middleware?(config)
53
+ return false if !config.respond_to?(:skip_rails_failover_active_record_middleware)
54
+ config.skip_rails_failover_active_record_middleware
33
55
  end
34
56
  end
35
57
  end
@@ -18,33 +18,41 @@ module RailsFailover
18
18
  end
19
19
 
20
20
  def self.logger
21
- @logger
21
+ if @logger
22
+ @logger
23
+ elsif defined?(::Rails)
24
+ ::Rails.logger
25
+ end
22
26
  end
23
27
 
24
- def self.verify_master_frequency_seconds=(seconds)
25
- @verify_master_frequency_seconds = seconds
28
+ def self.verify_primary_frequency_seconds=(seconds)
29
+ @verify_primary_frequency_seconds = seconds
26
30
  end
27
31
 
28
- def self.verify_master_frequency_seconds
29
- @verify_master_frequency_seconds || 5
32
+ def self.verify_primary_frequency_seconds
33
+ @verify_primary_frequency_seconds || 5
30
34
  end
31
35
 
32
- def self.register_master_down_callback(&block)
33
- @master_down_callbacks ||= []
34
- @master_down_callbacks.push(block)
36
+ def self.on_failover(&block)
37
+ @on_failover_callback = block
35
38
  end
36
39
 
37
- def self.master_down_callbacks
38
- @master_down_callbacks || []
40
+ def self.on_failover_callback
41
+ @on_failover_callback
39
42
  end
40
43
 
41
- def self.register_master_up_callback(&block)
42
- @master_up_callbacks ||= []
43
- @master_up_callbacks.push(block)
44
+ def self.on_fallback(&block)
45
+ @on_fallback_callback = block
44
46
  end
45
47
 
46
- def self.master_up_callbacks
47
- @master_up_callbacks || []
48
+ def self.on_fallback_callback
49
+ @on_fallback_callback
50
+ end
51
+
52
+ # For testing
53
+ def self.clear_callbacks
54
+ @on_fallback_callback = nil
55
+ @on_failover_callback = nil
48
56
  end
49
57
  end
50
58
  end
@@ -6,7 +6,7 @@ module RailsFailover
6
6
  class Redis
7
7
  class Connector < ::Redis::Client::Connector
8
8
  def initialize(options)
9
- options[:original_driver] = options[:driver]
9
+ orignal_driver = options[:driver]
10
10
 
11
11
  options[:driver] = Class.new(options[:driver]) do
12
12
  def self.connect(options)
@@ -22,18 +22,24 @@ module RailsFailover
22
22
  Errno::ETIMEDOUT,
23
23
  Errno::EINVAL => e
24
24
 
25
- Handler.instance.verify_master(options.dup)
25
+ Handler.instance.verify_primary(options)
26
26
  raise e
27
27
  end
28
28
  end
29
29
 
30
+ options[:original_driver] = orignal_driver
30
31
  options.delete(:connector)
31
- @options = options.dup
32
+ options[:id] ||= "#{options[:host]}:#{options[:port]}"
32
33
  @replica_options = replica_options(options)
34
+ @options = options.dup
33
35
  end
34
36
 
35
37
  def resolve
36
- Handler.instance.master ? @options : @replica_options
38
+ if Handler.instance.primary_down?(@options)
39
+ @replica_options
40
+ else
41
+ @options
42
+ end
37
43
  end
38
44
 
39
45
  def check(client)
@@ -50,7 +56,6 @@ module RailsFailover
50
56
  opts = options.dup
51
57
  opts[:host] = opts.delete(:replica_host)
52
58
  opts[:port] = opts.delete(:replica_port)
53
- opts[:driver] = opts.delete(:original_driver)
54
59
  opts
55
60
  end
56
61
  end
@@ -9,100 +9,184 @@ module RailsFailover
9
9
  include Singleton
10
10
  include MonitorMixin
11
11
 
12
- MASTER_ROLE_STATUS = "role:master"
13
- MASTER_LOADED_STATUS = "loading:0"
12
+ PRIMARY_ROLE_STATUS = "role:master"
13
+ PRIMARY_LOADED_STATUS = "loading:0"
14
+ VERIFY_FREQUENCY_BUFFER_PRECENT = 20
14
15
 
15
16
  def initialize
16
- @master = true
17
- @clients = []
17
+ @primaries_down = {}
18
+ @clients = {}
19
+ @ancestor_pid = Process.pid
18
20
 
19
21
  super() # Monitor#initialize
20
22
  end
21
23
 
22
- def verify_master(options)
24
+ def verify_primary(options)
23
25
  mon_synchronize do
26
+ primary_down(options)
27
+ disconnect_clients(options)
28
+
24
29
  return if @thread&.alive?
25
30
 
26
- self.master = false
27
- disconnect_clients
28
- RailsFailover::Redis.master_down_callbacks.each { |callback| callback.call }
31
+ logger&.warn "Failover for Redis has been initiated"
32
+
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
29
38
 
30
39
  @thread = Thread.new do
31
40
  loop do
32
- thread = Thread.new { initiate_fallback_to_master(options) }
41
+ thread = Thread.new { initiate_fallback_to_primary }
33
42
  thread.join
34
- break if self.master
35
- sleep (RailsFailover::Redis.verify_master_frequency_seconds + (Time.now.to_i % RailsFailover::Redis.verify_master_frequency_seconds))
36
- ensure
37
- thread.kill
43
+
44
+ if all_primaries_up
45
+ logger&.warn "Fallback to primary for Redis has been completed."
46
+
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
52
+
53
+ break
54
+ end
38
55
  end
39
56
  end
40
57
  end
41
58
  end
42
59
 
43
- def initiate_fallback_to_master(options)
44
- info = nil
45
-
46
- begin
47
- master_client = ::Redis::Client.new(options.dup)
48
- log "#{log_prefix}: Checking connection to master server..."
49
- info = master_client.call([:info])
50
- rescue => e
51
- log "#{log_prefix}: Connection to Master server failed with '#{e.message}'"
52
- ensure
53
- master_client&.disconnect
60
+ def initiate_fallback_to_primary
61
+ frequency = RailsFailover::Redis.verify_primary_frequency_seconds
62
+ sleep(frequency * ((rand(VERIFY_FREQUENCY_BUFFER_PRECENT) + 100) / 100.0))
63
+
64
+ active_primaries_keys = {}
65
+
66
+ primaries_down.each do |key, options|
67
+ info = nil
68
+ options = options.dup
69
+
70
+ begin
71
+ options[:driver] = options[:original_driver]
72
+ primary_client = ::Redis::Client.new(options)
73
+ logger&.debug "Checking connection to primary server (#{key})"
74
+ info = primary_client.call([:info])
75
+ rescue => e
76
+ logger&.debug "Connection to primary server (#{key}) failed with '#{e.message}'"
77
+ ensure
78
+ primary_client&.disconnect
79
+ end
80
+
81
+ if info && info.include?(PRIMARY_LOADED_STATUS) && info.include?(PRIMARY_ROLE_STATUS)
82
+ active_primaries_keys[key] = options
83
+ logger&.debug "Primary server (#{key}) is active, disconnecting clients from replica"
84
+ end
54
85
  end
55
86
 
56
- if info && info.include?(MASTER_LOADED_STATUS) && info.include?(MASTER_ROLE_STATUS)
57
- self.master = true
58
- log "#{log_prefix}: Master server is active, disconnecting clients from replica"
59
- disconnect_clients
60
- RailsFailover::Redis.master_up_callbacks.each { |callback| callback.call }
87
+ active_primaries_keys.each do |key, options|
88
+ primary_up(options)
89
+ disconnect_clients(options)
61
90
  end
62
91
  end
63
92
 
64
93
  def register_client(client)
94
+ key = client.options[:id]
95
+
65
96
  mon_synchronize do
66
- @clients << client
97
+ clients[key] ||= []
98
+ clients[key] << client
67
99
  end
68
100
  end
69
101
 
70
102
  def deregister_client(client)
103
+ key = client.options[:id]
104
+
71
105
  mon_synchronize do
72
- @clients.delete(client)
106
+ if clients[key]
107
+ clients[key].delete(client)
108
+
109
+ if clients[key].empty?
110
+ clients.delete(key)
111
+ end
112
+ end
73
113
  end
74
114
  end
75
115
 
76
- def clients
77
- mon_synchronize { @clients }
116
+ def primary_down?(options)
117
+ mon_synchronize do
118
+ primaries_down[options[:id]]
119
+ end
78
120
  end
79
121
 
80
- def master
81
- mon_synchronize { @master }
122
+ private
123
+
124
+ def all_primaries_up
125
+ mon_synchronize { primaries_down.empty? }
82
126
  end
83
127
 
84
- def master=(args)
85
- mon_synchronize { @master = args }
128
+ def primary_up(options)
129
+ mon_synchronize do
130
+ primaries_down.delete(options[:id])
131
+ end
86
132
  end
87
133
 
88
- private
134
+ def primary_down(options)
135
+ mon_synchronize do
136
+ primaries_down[options[:id]] = options.dup
137
+ end
138
+ end
139
+
140
+ def clients
141
+ process_pid = Process.pid
142
+ return @clients[process_pid] if @clients[process_pid]
89
143
 
90
- def disconnect_clients
91
144
  mon_synchronize do
92
- @clients.dup.each do |c|
93
- c.disconnect
145
+ if !@clients[process_pid]
146
+ @clients[process_pid] = {}
147
+
148
+ if process_pid != @ancestor_pid
149
+ @clients.delete(@ancestor_pid)
150
+ end
94
151
  end
152
+
153
+ @clients[process_pid]
95
154
  end
96
155
  end
97
156
 
98
- def log(message)
99
- if logger = RailsFailover::Redis.logger
100
- logger.warn(message)
157
+ def primaries_down
158
+ process_pid = Process.pid
159
+ return @primaries_down[process_pid] if @primaries_down[process_pid]
160
+
161
+ mon_synchronize do
162
+ if !@primaries_down[process_pid]
163
+ @primaries_down[process_pid] = @primaries_down[@ancestor_pid] || {}
164
+
165
+ if process_pid != @ancestor_pid
166
+ @primaries_down.delete(@ancestor_pid).each do |id, options|
167
+ verify_primary(options)
168
+ end
169
+ end
170
+ end
171
+
172
+ @primaries_down[process_pid]
173
+ end
174
+ end
175
+
176
+ def disconnect_clients(options)
177
+ key = options[:id]
178
+
179
+ mon_synchronize do
180
+ if clients[key]
181
+ clients[key].dup.each do |c|
182
+ c.disconnect
183
+ end
184
+ end
101
185
  end
102
186
  end
103
187
 
104
- def log_prefix
105
- "#{self.class}"
188
+ def logger
189
+ RailsFailover::Redis.logger
106
190
  end
107
191
  end
108
192
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsFailover
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.3"
5
5
  end
data/makefile CHANGED
@@ -12,7 +12,7 @@ setup_dummy_rails_server:
12
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
13
13
 
14
14
  start_dummy_rails_server:
15
- @cd spec/support/dummy_app && BUNDLE_GEMFILE=Gemfile bundle exec unicorn -c config/unicorn.conf.rb -D -E production
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
16
16
 
17
17
  stop_dummy_rails_server:
18
18
  @kill -TERM $(shell cat spec/support/dummy_app/tmp/pids/unicorn.pid)
@@ -21,8 +21,6 @@ Gem::Specification.new do |spec|
21
21
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
22
  spec.require_paths = ["lib"]
23
23
 
24
- spec.add_dependency 'listen', "~> 3.2"
25
-
26
24
  ["activerecord", "railties"].each do |gem_name|
27
25
  spec.add_dependency gem_name, "~> 6.0"
28
26
  end
data/redis.mk CHANGED
@@ -1,7 +1,7 @@
1
1
  REDIS_PORT := 6381
2
2
  REDIS_PID_PATH := /tmp/redis.pid
3
3
  REDIS_SOCKET_PATH := /tmp/redis.sock
4
- REDIS_DBFILENAME := master.rdb
4
+ REDIS_DBFILENAME := primary.rdb
5
5
  REDIS_REPLICA_PORT := 6382
6
6
  REDIS_REPLICA_PID_PATH := /tmp/redis_replica.pid
7
7
  REDIS_REPLICA_SOCKET_PATH := /tmp/redis_replica.sock
@@ -12,17 +12,17 @@ redis: start_redis test_redis stop_redis
12
12
  test_redis:
13
13
  @REDIS=1 bundle exec rspec --tag type:redis ${RSPEC_PATH}
14
14
 
15
- start_redis: start_redis_master start_redis_replica
16
- stop_redis: stop_redis_replica stop_redis_master
15
+ start_redis: start_redis_primary start_redis_replica
16
+ stop_redis: stop_redis_replica stop_redis_primary
17
17
 
18
- stop_redis_master:
18
+ stop_redis_primary:
19
19
  @redis-cli -p ${REDIS_PORT} shutdown
20
20
 
21
- start_redis_master:
21
+ start_redis_primary:
22
22
  @redis-server --daemonize yes --pidfile ${REDIS_PID_PATH} --port ${REDIS_PORT} --unixsocket ${REDIS_SOCKET_PATH} --dbfilename ${REDIS_DBFILENAME} --logfile /dev/null
23
23
 
24
24
  stop_redis_replica:
25
25
  @redis-cli -p ${REDIS_REPLICA_PORT} shutdown
26
26
 
27
27
  start_redis_replica:
28
- @redis-server --daemonize yes --pidfile ${REDIS_REPLICA_PID_PATH} --port ${REDIS_REPLICA_PORT} --unixsocket ${REDIS_REPLICA_SOCKET_PATH} --replicaof 127.0.0.1 ${REDIS_PORT} --dbfilename ${REDIS_REPLICA_DBFILENAME} --logfile /dev/null
28
+ @redis-server --daemonize yes --pidfile ${REDIS_REPLICA_PID_PATH} --port ${REDIS_REPLICA_PORT} --unixsocket ${REDIS_REPLICA_SOCKET_PATH} --slaveof 127.0.0.1 ${REDIS_PORT} --dbfilename ${REDIS_REPLICA_DBFILENAME} --logfile /dev/null
metadata CHANGED
@@ -1,29 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_failover
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.3
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-05-28 00:00:00.000000000 Z
11
+ date: 2020-07-20 00:00:00.000000000 Z
12
12
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: listen
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - "~>"
18
- - !ruby/object:Gem::Version
19
- version: '3.2'
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - "~>"
25
- - !ruby/object:Gem::Version
26
- version: '3.2'
27
13
  - !ruby/object:Gem::Dependency
28
14
  name: activerecord
29
15
  requirement: !ruby/object:Gem::Requirement
@@ -63,6 +49,7 @@ files:
63
49
  - ".rspec"
64
50
  - ".rubocop.yml"
65
51
  - ".travis.yml"
52
+ - CHANGELOG.md
66
53
  - CODE_OF_CONDUCT.md
67
54
  - Gemfile
68
55
  - Gemfile.lock
@@ -105,7 +92,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
105
92
  - !ruby/object:Gem::Version
106
93
  version: '0'
107
94
  requirements: []
108
- rubygems_version: 3.0.3
95
+ rubygems_version: 3.1.2
109
96
  signing_key:
110
97
  specification_version: 4
111
98
  summary: Failover for ActiveRecord and Redis