rails_failover 0.3.0 → 0.5.3

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