rails_failover 0.4.0 → 0.5.0

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: 7fc58e5f3f5cbc45985b03f862b138aea0ef7723a2a8bba5625f4dea0efae6ee
4
- data.tar.gz: c1703a05fd75b726bb3b30d06d87b038651e5a6a178a397eb6f6ced1c7b91e18
3
+ metadata.gz: 6ab1bfea9c1e882693a3455a73601edce7c0ed2af5b50ec1276cf95e1dda51d5
4
+ data.tar.gz: 4cf4386f6d51dbae3f82bedcb56c8da3ac4674b254a1b475e2822b46d4a5b8ed
5
5
  SHA512:
6
- metadata.gz: 9bf3eb493c5ae85af24d7ada304b70af918b4feb34b7b9ca6ee498559b228add84aa93bff0e56d4114c11459bf3ff0ca822c1528f06b94f24a105a1a29b3b8bd
7
- data.tar.gz: 118761f0ae8c211e171c9bde1d2dbddb2967c5f5b7ed6da535e1ef318761f27701f64dac92579cad983b64307c4ad21e15bb1b600d07e45ae56963047252fe38
6
+ metadata.gz: 4b22d9f037c18ca0c82a61764494e5b8a3a41a30a514b12f8d9dec1737e24fb251729457abb8e0f3ab596ab511db98e776740b7a62646313dd542c10c945cbce
7
+ data.tar.gz: a653959c37717e01e30ff82c0b1594b71e2a402c13073c6e0bf1ee6176d588dc2fcd3c165cd69e294c7fbe0b66bc0e944e36e5fb176b7306ac92dc028a15fb0b
@@ -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
 
data/Gemfile CHANGED
@@ -6,15 +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
- gem 'rack'
20
- 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.4.0)
4
+ rails_failover (0.5.0)
5
5
  activerecord (~> 6.0)
6
- listen (~> 3.2)
7
6
  railties (~> 6.0)
8
7
 
9
8
  GEM
@@ -40,12 +39,8 @@ 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
44
  loofah (2.5.0)
50
45
  crass (~> 1.0.2)
51
46
  nokogiri (>= 1.5.9)
@@ -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)
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)
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).
@@ -27,17 +27,14 @@ module RailsFailover
27
27
  @verify_primary_frequency_seconds || 5
28
28
  end
29
29
 
30
- def self.establish_reading_connection(connection_spec)
30
+ def self.establish_reading_connection(handler, connection_spec)
31
31
  config = connection_spec.config
32
32
 
33
33
  if config[:replica_host] && config[:replica_port]
34
34
  replica_config = config.dup
35
-
36
35
  replica_config[:host] = replica_config.delete(:replica_host)
37
36
  replica_config[:port] = replica_config.delete(:replica_port)
38
37
  replica_config[:replica] = true
39
-
40
- handler = ::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.reading_role]
41
38
  handler.establish_connection(replica_config)
42
39
  end
43
40
  end
@@ -45,5 +42,21 @@ module RailsFailover
45
42
  def self.register_force_reading_role_callback(&block)
46
43
  Middleware.force_reading_role_callback = block
47
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
48
61
  end
49
62
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require 'singleton'
3
3
  require 'monitor'
4
- require 'listen'
5
4
  require 'fileutils'
6
5
 
7
6
  module RailsFailover
@@ -17,54 +16,39 @@ module RailsFailover
17
16
  @primaries_down = {}
18
17
  @ancestor_pid = Process.pid
19
18
 
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
19
  super() # Monitor#initialize
47
20
  end
48
21
 
49
- def start_listener
50
- @listener.start
51
- end
52
-
53
- def verify_primary(handler_key, publish: true)
22
+ def verify_primary(handler_key)
54
23
  mon_synchronize do
55
24
  primary_down(handler_key)
56
- publish_primary_down(handler_key) if publish
57
- return if @thread&.alive? && @thread["pid"] == Process.pid
25
+ return if @thread&.alive?
26
+
27
+ logger.warn "Failover for ActiveRecord has been initiated"
28
+
29
+ begin
30
+ RailsFailover::ActiveRecord.on_failover_callback&.call
31
+ rescue => e
32
+ logger.warn("RailsFailover::ActiveRecord.on_failover_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
33
+ end
58
34
 
59
35
  @thread = Thread.new do
60
36
  loop do
61
37
  initiate_fallback_to_primary
62
- break if all_primaries_up
38
+
39
+ if all_primaries_up
40
+ logger.warn "Fallback to primary for ActiveRecord has been completed."
41
+
42
+ begin
43
+ RailsFailover::ActiveRecord.on_fallback_callback&.call
44
+ rescue => e
45
+ logger.warn("RailsFailover::ActiveRecord.on_fallback_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
46
+ end
47
+
48
+ break
49
+ end
63
50
  end
64
51
  end
65
-
66
- @thread["pid"] = Process.pid
67
- @thread
68
52
  end
69
53
  end
70
54
 
@@ -78,27 +62,26 @@ module RailsFailover
78
62
  connection_handler = ::ActiveRecord::Base.connection_handlers[handler_key]
79
63
  spec = connection_handler.retrieve_connection_pool(spec_name).spec
80
64
  config = spec.config
81
- logger.warn "#{Process.pid} Checking server for '#{handler_key} #{spec_name}'..."
65
+ logger.debug "#{Process.pid} Checking server for '#{handler_key} #{spec_name}'..."
82
66
  connection_active = false
83
67
 
84
68
  begin
85
69
  connection = ::ActiveRecord::Base.public_send(spec.adapter_method, config)
86
70
  connection_active = connection.active?
87
71
  rescue => e
88
- logger.warn "#{Process.pid} Connection to server for '#{handler_key} #{spec_name}' failed with '#{e.message}'"
72
+ logger.debug "#{Process.pid} Connection to server for '#{handler_key} #{spec_name}' failed with '#{e.message}'"
89
73
  ensure
90
74
  connection.disconnect! if connection
91
75
  end
92
76
 
93
77
  if connection_active
94
- logger.warn "#{Process.pid} Server for '#{handler_key} #{spec_name}' is active."
78
+ logger.debug "#{Process.pid} Server for '#{handler_key} #{spec_name}' is active."
95
79
  active_handler_keys << handler_key
96
80
  end
97
81
  end
98
82
 
99
83
  active_handler_keys.each do |handler_key|
100
84
  primary_up(handler_key)
101
- publish_primary_up(handler_key)
102
85
  end
103
86
  end
104
87
 
@@ -114,25 +97,12 @@ module RailsFailover
114
97
  end
115
98
  end
116
99
 
117
-
118
100
  def primary_down(handler_key)
119
101
  mon_synchronize do
120
102
  primaries_down[handler_key] = true
121
103
  end
122
104
  end
123
105
 
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
106
  def primary_up(handler_key)
137
107
  mon_synchronize do
138
108
  primaries_down.delete(handler_key)
@@ -165,7 +135,7 @@ module RailsFailover
165
135
  end
166
136
 
167
137
  def logger
168
- Rails.logger
138
+ ::Rails.logger
169
139
  end
170
140
  end
171
141
  end
@@ -2,57 +2,98 @@
2
2
 
3
3
  module RailsFailover
4
4
  module ActiveRecord
5
+ class Interceptor
6
+ def self.adapter_error
7
+ @adapter_error ||= begin
8
+ if defined?(::PG)
9
+ ::PG::Error
10
+ elsif defined?(::Mysql2)
11
+ ::Mysql2::Error
12
+ end
13
+ end
14
+ end
15
+
16
+ def self.handle(request, exception)
17
+ if (resolve_cause(exception).is_a?(adapter_error))
18
+ Handler.instance.verify_primary(request.env[Middleware::WRITING_ROLE_HEADER])
19
+ end
20
+ end
21
+
22
+ def self.resolve_cause(exception)
23
+ if exception.cause
24
+ resolve_cause(exception.cause)
25
+ else
26
+ exception
27
+ end
28
+ end
29
+ end
30
+
5
31
  class Middleware
6
32
  class << self
7
33
  attr_accessor :force_reading_role_callback
8
-
9
- def adapter_error
10
- @adapter_error ||= begin
11
- if defined?(::PG)
12
- ::PG::Error
13
- elsif defined?(::SQLite3)
14
- ::SQLite3::Exception
15
- elsif defined?(::Mysql2)
16
- ::Mysql2::Error
17
- end
18
- end
19
- end
20
34
  end
21
35
 
22
- ROLE_HEADER = "rails_failover.role"
36
+ CURRENT_ROLE_HEADER = "rails_failover.role"
37
+ WRITING_ROLE_HEADER = "rails_failover.writing_role"
23
38
 
24
39
  def initialize(app)
25
40
  @app = app
26
41
  end
27
42
 
28
43
  def call(env)
29
- writing_role = ::ActiveRecord::Base.writing_role
44
+ current_role = ::ActiveRecord::Base.current_role || ::ActiveRecord::Base.writing_role
45
+ is_writing_role = current_role.to_s.end_with?(::ActiveRecord::Base.writing_role.to_s)
46
+ writing_role = resolve_writing_role(current_role, is_writing_role)
30
47
 
31
48
  role =
32
- if primary_down = Handler.instance.primary_down?(writing_role) || self.class.force_reading_role_callback&.call(env)
33
- ::ActiveRecord::Base.reading_role
49
+ if primary_down = self.class.force_reading_role_callback&.call(env) || Handler.instance.primary_down?(writing_role)
50
+ reading_role = resolve_reading_role(current_role, is_writing_role)
51
+ ensure_reading_connection_established!(writing_role: writing_role, reading_role: reading_role)
52
+ reading_role
34
53
  else
35
- ::ActiveRecord::Base.writing_role
54
+ writing_role
36
55
  end
37
56
 
38
57
  ::ActiveRecord::Base.connected_to(role: role) do
39
- env[ROLE_HEADER] = role
58
+ env[CURRENT_ROLE_HEADER] = role
59
+ env[WRITING_ROLE_HEADER] = writing_role
40
60
  @app.call(env)
41
61
  end
42
- rescue Exception => e
43
- if (resolve_cause(e).is_a?(self.class.adapter_error))
44
- Handler.instance.verify_primary(writing_role)
45
- raise
46
- end
47
62
  end
48
63
 
49
64
  private
50
65
 
51
- def resolve_cause(error)
52
- if error.cause
53
- resolve_cause(error.cause)
66
+ def ensure_reading_connection_established!(writing_role:, reading_role:)
67
+ ::ActiveRecord::Base.connection_handlers[reading_role] ||= begin
68
+ handler = ::ActiveRecord::ConnectionAdapters::ConnectionHandler.new
69
+
70
+ ::ActiveRecord::Base.connection_handlers[writing_role].connection_pools.each do |pool|
71
+ ::RailsFailover::ActiveRecord.establish_reading_connection(handler, pool.spec)
72
+ end
73
+
74
+ handler
75
+ end
76
+ end
77
+
78
+ def resolve_writing_role(current_role, is_writing_role)
79
+ if is_writing_role
80
+ current_role
81
+ else
82
+ current_role.to_s.sub(
83
+ /#{::ActiveRecord::Base.reading_role}$/,
84
+ ::ActiveRecord::Base.writing_role.to_s
85
+ ).to_sym
86
+ end
87
+ end
88
+
89
+ def resolve_reading_role(current_role, is_writing_role)
90
+ if is_writing_role
91
+ current_role.to_s.sub(
92
+ /#{::ActiveRecord::Base.writing_role}$/,
93
+ ::ActiveRecord::Base.reading_role.to_s
94
+ ).to_sym
54
95
  else
55
- error
96
+ current_role
56
97
  end
57
98
  end
58
99
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RailsFailover
2
4
  module ActiveRecord
3
5
  class Railtie < ::Rails::Railtie
@@ -11,7 +13,10 @@ module RailsFailover
11
13
  ::ActiveRecord::ConnectionAdapters::ConnectionHandler.new
12
14
 
13
15
  ::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.writing_role].connection_pools.each do |connection_pool|
14
- RailsFailover::ActiveRecord.establish_reading_connection(connection_pool.spec)
16
+ RailsFailover::ActiveRecord.establish_reading_connection(
17
+ ::ActiveRecord::Base.connection_handlers[::ActiveRecord::Base.reading_role],
18
+ connection_pool.spec
19
+ )
15
20
  end
16
21
 
17
22
  begin
@@ -26,10 +31,18 @@ module RailsFailover
26
31
  end
27
32
 
28
33
  initializer "rails_failover.insert_middleware" do |app|
29
- app.middleware.insert_after(
30
- ::ActionDispatch::ActionableExceptions,
31
- ::RailsFailover::ActiveRecord::Middleware
32
- )
34
+ ActionDispatch::DebugExceptions.register_interceptor do |request, exception|
35
+ RailsFailover::ActiveRecord::Interceptor.handle(request, exception)
36
+ end
37
+
38
+ if !skip_middleware?(app.config)
39
+ app.middleware.unshift(::RailsFailover::ActiveRecord::Middleware)
40
+ end
41
+ end
42
+
43
+ def skip_middleware?(config)
44
+ return false if !config.respond_to?(:skip_rails_failover_active_record_middleware)
45
+ config.skip_rails_failover_active_record_middleware
33
46
  end
34
47
  end
35
48
  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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'monitor'
4
4
  require 'singleton'
5
+ require 'digest'
5
6
 
6
7
  module RailsFailover
7
8
  class Redis
@@ -9,100 +10,189 @@ module RailsFailover
9
10
  include Singleton
10
11
  include MonitorMixin
11
12
 
12
- MASTER_ROLE_STATUS = "role:master"
13
- MASTER_LOADED_STATUS = "loading:0"
13
+ PRIMARY_ROLE_STATUS = "role:master"
14
+ PRIMARY_LOADED_STATUS = "loading:0"
15
+ VERIFY_FREQUENCY_BUFFER_PRECENT = 20
16
+ SEPERATOR = "__RAILS_FAILOVER__"
14
17
 
15
18
  def initialize
16
- @master = true
17
- @clients = []
19
+ @primaries_down = {}
20
+ @clients = {}
21
+ @ancestor_pid = Process.pid
18
22
 
19
23
  super() # Monitor#initialize
20
24
  end
21
25
 
22
- def verify_master(options)
26
+ def verify_primary(options)
23
27
  mon_synchronize do
28
+ primary_down(options)
29
+ disconnect_clients(options)
30
+
24
31
  return if @thread&.alive?
25
32
 
26
- self.master = false
27
- disconnect_clients
28
- RailsFailover::Redis.master_down_callbacks.each { |callback| callback.call }
33
+ logger&.warn "Failover for Redis has been initiated"
34
+
35
+ begin
36
+ RailsFailover::Redis.on_failover_callback&.call
37
+ rescue => e
38
+ logger&.warn("RailsFailover::Redis.on_failover_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
39
+ end
29
40
 
30
41
  @thread = Thread.new do
31
42
  loop do
32
- thread = Thread.new { initiate_fallback_to_master(options) }
43
+ thread = Thread.new { initiate_fallback_to_primary }
33
44
  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
45
+
46
+ if all_primaries_up
47
+ logger&.warn "Fallback to primary for Redis has been completed."
48
+
49
+ begin
50
+ RailsFailover::Redis.on_fallback_callback&.call
51
+ rescue => e
52
+ logger&.warn("RailsFailover::Redis.on_fallback_callback failed: #{e.class} #{e.message}\n#{e.backtrace.join("\n")}")
53
+ end
54
+
55
+ break
56
+ end
38
57
  end
39
58
  end
40
59
  end
41
60
  end
42
61
 
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
62
+ def initiate_fallback_to_primary
63
+ frequency = RailsFailover::Redis.verify_primary_frequency_seconds
64
+ sleep(frequency * ((rand(VERIFY_FREQUENCY_BUFFER_PRECENT) + 100) / 100.0))
65
+
66
+ active_primaries_keys = {}
67
+
68
+ primaries_down.each do |key, options|
69
+ info = nil
70
+ options = options.dup
71
+
72
+ begin
73
+ options[:driver] = options[:original_driver]
74
+ primary_client = ::Redis::Client.new(options)
75
+ logger&.debug "Checking connection to primary server (#{key})"
76
+ info = primary_client.call([:info])
77
+ rescue => e
78
+ logger&.debug "Connection to primary server (#{key}) failed with '#{e.message}'"
79
+ ensure
80
+ primary_client&.disconnect
81
+ end
82
+
83
+ if info && info.include?(PRIMARY_LOADED_STATUS) && info.include?(PRIMARY_ROLE_STATUS)
84
+ active_primaries_keys[key] = options
85
+ logger&.debug "Primary server (#{key}) is active, disconnecting clients from replica"
86
+ end
54
87
  end
55
88
 
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 }
89
+ active_primaries_keys.each do |key, options|
90
+ primary_up(options)
91
+ disconnect_clients(options)
61
92
  end
62
93
  end
63
94
 
64
95
  def register_client(client)
96
+ key = client.options[:id]
97
+
65
98
  mon_synchronize do
66
- @clients << client
99
+ clients[key] ||= []
100
+ clients[key] << client
67
101
  end
68
102
  end
69
103
 
70
104
  def deregister_client(client)
105
+ key = client.options[:id]
106
+
71
107
  mon_synchronize do
72
- @clients.delete(client)
108
+ if clients[key]
109
+ clients[key].delete(client)
110
+
111
+ if clients[key].empty?
112
+ clients.delete(key)
113
+ end
114
+ end
73
115
  end
74
116
  end
75
117
 
76
- def clients
77
- mon_synchronize { @clients }
118
+ def primary_down?(options)
119
+ mon_synchronize do
120
+ primaries_down[options[:id]]
121
+ end
78
122
  end
79
123
 
80
- def master
81
- mon_synchronize { @master }
124
+ private
125
+
126
+ def id_digest(id)
127
+ Digest::MD5.hexdigest(id)
82
128
  end
83
129
 
84
- def master=(args)
85
- mon_synchronize { @master = args }
130
+ def all_primaries_up
131
+ mon_synchronize { primaries_down.empty? }
86
132
  end
87
133
 
88
- private
134
+ def primary_up(options)
135
+ mon_synchronize do
136
+ primaries_down.delete(options[:id])
137
+ end
138
+ end
89
139
 
90
- def disconnect_clients
140
+ def primary_down(options)
91
141
  mon_synchronize do
92
- @clients.dup.each do |c|
93
- c.disconnect
142
+ primaries_down[options[:id]] = options.dup
143
+ end
144
+ end
145
+
146
+ def clients
147
+ process_pid = Process.pid
148
+ return @clients[process_pid] if @clients[process_pid]
149
+
150
+ mon_synchronize do
151
+ if !@clients[process_pid]
152
+ @clients[process_pid] = {}
153
+
154
+ if process_pid != @ancestor_pid
155
+ @clients.delete(@ancestor_pid)
156
+ end
157
+ end
158
+
159
+ @clients[process_pid]
160
+ end
161
+ end
162
+
163
+ def primaries_down
164
+ process_pid = Process.pid
165
+ return @primaries_down[process_pid] if @primaries_down[process_pid]
166
+
167
+ mon_synchronize do
168
+ if !@primaries_down[process_pid]
169
+ @primaries_down[process_pid] = @primaries_down[@ancestor_pid] || {}
170
+
171
+ if process_pid != @ancestor_pid
172
+ @primaries_down.delete(@ancestor_pid).each do |id, options|
173
+ verify_primary(options)
174
+ end
175
+ end
94
176
  end
177
+
178
+ @primaries_down[process_pid]
95
179
  end
96
180
  end
97
181
 
98
- def log(message)
99
- if logger = RailsFailover::Redis.logger
100
- logger.warn(message)
182
+ def disconnect_clients(options)
183
+ key = options[:id]
184
+
185
+ mon_synchronize do
186
+ if clients[key]
187
+ clients[key].dup.each do |c|
188
+ c.disconnect
189
+ end
190
+ end
101
191
  end
102
192
  end
103
193
 
104
- def log_prefix
105
- "#{self.class}"
194
+ def logger
195
+ RailsFailover::Redis.logger
106
196
  end
107
197
  end
108
198
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsFailover
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
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.4.0
4
+ version: 0.5.0
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-06-15 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
@@ -105,7 +91,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
105
91
  - !ruby/object:Gem::Version
106
92
  version: '0'
107
93
  requirements: []
108
- rubygems_version: 3.0.3
94
+ rubygems_version: 3.1.2
109
95
  signing_key:
110
96
  specification_version: 4
111
97
  summary: Failover for ActiveRecord and Redis