rails_failover 0.4.0 → 0.5.0

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