redis_failover 0.1.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.
- data/.gitignore +17 -0
- data/Changes.md +4 -0
- data/Gemfile +2 -0
- data/LICENSE +22 -0
- data/README.md +106 -0
- data/Rakefile +9 -0
- data/bin/redis_failover_server +6 -0
- data/lib/redis_failover.rb +16 -0
- data/lib/redis_failover/cli.rb +47 -0
- data/lib/redis_failover/client.rb +198 -0
- data/lib/redis_failover/errors.rb +25 -0
- data/lib/redis_failover/node.rb +101 -0
- data/lib/redis_failover/node_manager.rb +151 -0
- data/lib/redis_failover/node_watcher.rb +46 -0
- data/lib/redis_failover/runner.rb +31 -0
- data/lib/redis_failover/server.rb +13 -0
- data/lib/redis_failover/util.rb +29 -0
- data/lib/redis_failover/version.rb +3 -0
- data/redis_failover.gemspec +25 -0
- data/spec/cli_spec.rb +39 -0
- data/spec/client_spec.rb +62 -0
- data/spec/node_manager_spec.rb +76 -0
- data/spec/node_spec.rb +76 -0
- data/spec/node_watcher_spec.rb +43 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/support/node_manager_stub.rb +43 -0
- data/spec/support/redis_stub.rb +82 -0
- data/spec/util_spec.rb +11 -0
- metadata +157 -0
@@ -0,0 +1,101 @@
|
|
1
|
+
module RedisFailover
|
2
|
+
# Represents a redis node (master or slave).
|
3
|
+
class Node
|
4
|
+
include Util
|
5
|
+
|
6
|
+
attr_reader :host, :port
|
7
|
+
|
8
|
+
def initialize(options = {})
|
9
|
+
@host = options.fetch(:host) { raise InvalidNodeError, 'missing host'}
|
10
|
+
@port = Integer(options[:port] || 6379)
|
11
|
+
@password = options[:password]
|
12
|
+
end
|
13
|
+
|
14
|
+
def ping
|
15
|
+
perform_operation do
|
16
|
+
redis.ping
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def master?
|
21
|
+
role == 'master'
|
22
|
+
end
|
23
|
+
|
24
|
+
def slave?
|
25
|
+
!master?
|
26
|
+
end
|
27
|
+
|
28
|
+
def wait_until_unreachable
|
29
|
+
perform_operation do
|
30
|
+
redis.blpop(wait_key, 0)
|
31
|
+
redis.del(wait_key)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def stop_waiting
|
36
|
+
perform_operation do
|
37
|
+
redis.lpush(wait_key, '1')
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def make_slave!(master)
|
42
|
+
perform_operation do
|
43
|
+
redis.slaveof(master.host, master.port)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def make_master!
|
48
|
+
perform_operation do
|
49
|
+
# yes, this is a real redis operation!
|
50
|
+
redis.slaveof('no', 'one')
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def inspect
|
55
|
+
"<RedisFailover::Node #{to_s}>"
|
56
|
+
end
|
57
|
+
|
58
|
+
def to_s
|
59
|
+
"#{@host}:#{@port}"
|
60
|
+
end
|
61
|
+
|
62
|
+
def ==(other)
|
63
|
+
return false unless other.is_a?(Node)
|
64
|
+
return true if self.equal?(other)
|
65
|
+
[host, port] == [other.host, other.port]
|
66
|
+
end
|
67
|
+
alias_method :eql?, :==
|
68
|
+
|
69
|
+
def hash
|
70
|
+
to_s.hash
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
def role
|
76
|
+
fetch_info[:role]
|
77
|
+
end
|
78
|
+
|
79
|
+
def fetch_info
|
80
|
+
perform_operation do
|
81
|
+
symbolize_keys(redis.info)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def wait_key
|
86
|
+
@wait_key ||= "_redis_failover_#{SecureRandom.hex(32)}"
|
87
|
+
end
|
88
|
+
|
89
|
+
def redis
|
90
|
+
Redis.new(:host => @host, :password => @password, :port => @port)
|
91
|
+
rescue
|
92
|
+
raise NodeUnreachableError.new(self)
|
93
|
+
end
|
94
|
+
|
95
|
+
def perform_operation
|
96
|
+
yield
|
97
|
+
rescue
|
98
|
+
raise NodeUnreachableError.new(self)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
module RedisFailover
|
2
|
+
# NodeManager manages a list of redis nodes.
|
3
|
+
class NodeManager
|
4
|
+
include Util
|
5
|
+
|
6
|
+
def initialize(options)
|
7
|
+
@options = options
|
8
|
+
@master, @slaves = parse_nodes
|
9
|
+
@unreachable = []
|
10
|
+
@queue = Queue.new
|
11
|
+
@lock = Mutex.new
|
12
|
+
end
|
13
|
+
|
14
|
+
def start
|
15
|
+
spawn_watchers
|
16
|
+
handle_state_changes
|
17
|
+
end
|
18
|
+
|
19
|
+
def notify_state_change(node, state)
|
20
|
+
@queue << [node, state]
|
21
|
+
end
|
22
|
+
|
23
|
+
def nodes
|
24
|
+
@lock.synchronize do
|
25
|
+
{
|
26
|
+
:master => @master ? @master.to_s : nil,
|
27
|
+
:slaves => @slaves.map(&:to_s),
|
28
|
+
:unreachable => @unreachable.map(&:to_s)
|
29
|
+
}
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def shutdown
|
34
|
+
@watchers.each(&:shutdown)
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def handle_state_changes
|
40
|
+
while state_change = @queue.pop
|
41
|
+
@lock.synchronize do
|
42
|
+
node, state = state_change
|
43
|
+
begin
|
44
|
+
case state
|
45
|
+
when :unreachable then handle_unreachable(node)
|
46
|
+
when :reachable then handle_reachable(node)
|
47
|
+
else raise InvalidNodeStateError.new(node, state)
|
48
|
+
end
|
49
|
+
rescue NodeUnreachableError
|
50
|
+
# node suddenly became unreachable, silently
|
51
|
+
# handle since the watcher will take care of
|
52
|
+
# keeping track of the node
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def handle_unreachable(node)
|
59
|
+
# no-op if we already know about this node
|
60
|
+
return if @unreachable.include?(node)
|
61
|
+
logger.info("Handling unreachable node: #{node}")
|
62
|
+
|
63
|
+
# find a new master if this node was a master
|
64
|
+
if node == @master
|
65
|
+
logger.info("Demoting currently unreachable master #{node}.")
|
66
|
+
promote_new_master
|
67
|
+
else
|
68
|
+
@slaves.delete(node)
|
69
|
+
end
|
70
|
+
|
71
|
+
@unreachable << node
|
72
|
+
end
|
73
|
+
|
74
|
+
def handle_reachable(node)
|
75
|
+
# no-op if we already know about this node
|
76
|
+
return if @master == node || @slaves.include?(node)
|
77
|
+
logger.info("Handling reachable node: #{node}")
|
78
|
+
|
79
|
+
@unreachable.delete(node)
|
80
|
+
@slaves << node
|
81
|
+
if @master
|
82
|
+
# master already exists, make a slave
|
83
|
+
node.make_slave!(@master)
|
84
|
+
else
|
85
|
+
# no master exists, make this the new master
|
86
|
+
promote_new_master
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def promote_new_master
|
91
|
+
@master = nil
|
92
|
+
|
93
|
+
if @slaves.empty?
|
94
|
+
logger.error('Failed to promote a new master since no slaves available.')
|
95
|
+
return
|
96
|
+
end
|
97
|
+
|
98
|
+
# make a slave the new master
|
99
|
+
node = @slaves.pop
|
100
|
+
node.make_master!
|
101
|
+
@master = node
|
102
|
+
|
103
|
+
# switch existing slaves to point to new master
|
104
|
+
redirect_slaves
|
105
|
+
logger.info("Successfully promoted #{node} to master.")
|
106
|
+
end
|
107
|
+
|
108
|
+
def parse_nodes
|
109
|
+
nodes = @options[:nodes].map { |opts| Node.new(opts) }
|
110
|
+
raise NoMasterError unless master = find_master(nodes)
|
111
|
+
slaves = nodes - [master]
|
112
|
+
|
113
|
+
logger.info("Managing master (#{master}) and slaves" +
|
114
|
+
" (#{slaves.map(&:to_s).join(', ')})")
|
115
|
+
|
116
|
+
[master, slaves]
|
117
|
+
end
|
118
|
+
|
119
|
+
def spawn_watchers
|
120
|
+
@watchers = [@master, *@slaves].map do |node|
|
121
|
+
NodeWatcher.new(self, node, @options[:max_failures] || 3)
|
122
|
+
end
|
123
|
+
@watchers.each(&:watch)
|
124
|
+
end
|
125
|
+
|
126
|
+
def find_master(nodes)
|
127
|
+
# try to find the master - if the actual master is currently
|
128
|
+
# down, it will be handled by its watcher
|
129
|
+
nodes.find do |node|
|
130
|
+
begin
|
131
|
+
node.master?
|
132
|
+
rescue NodeUnreachableError
|
133
|
+
# will eventually be handled by watcher
|
134
|
+
false
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def redirect_slaves
|
140
|
+
# redirect each slave to a new master - if an actual slave is
|
141
|
+
# currently down, it will be handled by its watcher
|
142
|
+
@slaves.each do |slave|
|
143
|
+
begin
|
144
|
+
slave.make_slave!(@master)
|
145
|
+
rescue NodeUnreachableError
|
146
|
+
# will eventually be handled by watcher
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module RedisFailover
|
2
|
+
# Watches a specific redis node for its reachability.
|
3
|
+
class NodeWatcher
|
4
|
+
def initialize(manager, node, max_failures)
|
5
|
+
@manager = manager
|
6
|
+
@node = node
|
7
|
+
@max_failures = max_failures
|
8
|
+
@monitor_thread = nil
|
9
|
+
@done = false
|
10
|
+
end
|
11
|
+
|
12
|
+
def watch
|
13
|
+
@monitor_thread = Thread.new { monitor_node }
|
14
|
+
self
|
15
|
+
end
|
16
|
+
|
17
|
+
def shutdown
|
18
|
+
@done = true
|
19
|
+
@node.stop_waiting
|
20
|
+
@monitor_thread.join if @monitor_thread
|
21
|
+
rescue
|
22
|
+
# best effort
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def monitor_node
|
28
|
+
failures = 0
|
29
|
+
|
30
|
+
begin
|
31
|
+
return if @done
|
32
|
+
@node.ping
|
33
|
+
failures = 0
|
34
|
+
@manager.notify_state_change(@node, :reachable)
|
35
|
+
@node.wait_until_unreachable
|
36
|
+
rescue NodeUnreachableError
|
37
|
+
failures += 1
|
38
|
+
if failures >= @max_failures
|
39
|
+
@manager.notify_state_change(@node, :unreachable)
|
40
|
+
failures = 0
|
41
|
+
end
|
42
|
+
sleep(3) && retry
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module RedisFailover
|
2
|
+
# Runner is responsible for bootstrapping the redis failover server.
|
3
|
+
class Runner
|
4
|
+
def self.run(options)
|
5
|
+
options = CLI.parse(options)
|
6
|
+
Util.logger.info("Redis Failover Server starting on port #{options[:port]}")
|
7
|
+
Server.set(:port, options[:port])
|
8
|
+
@node_manager = NodeManager.new(options)
|
9
|
+
server_thread = Thread.new { Server.run! { |server| trap_signals } }
|
10
|
+
node_manager_thread = Thread.new { @node_manager.start }
|
11
|
+
[server_thread, node_manager_thread].each(&:join)
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.node_manager
|
15
|
+
@node_manager
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.trap_signals
|
19
|
+
[:INT, :TERM].each do |signal|
|
20
|
+
previous_signal = trap(signal) do
|
21
|
+
Util.logger.info('Shutting down ...')
|
22
|
+
if previous_signal && previous_signal.respond_to?(:call)
|
23
|
+
previous_signal.call
|
24
|
+
end
|
25
|
+
@node_manager.shutdown
|
26
|
+
exit(0)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'sinatra'
|
2
|
+
|
3
|
+
module RedisFailover
|
4
|
+
# Serves as an endpoint for discovering the current redis master and slaves.
|
5
|
+
class Server < Sinatra::Base
|
6
|
+
disable :logging
|
7
|
+
|
8
|
+
get '/redis_servers' do
|
9
|
+
content_type :json
|
10
|
+
MultiJson.encode(Runner.node_manager.nodes)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module RedisFailover
|
2
|
+
# Common utiilty methods.
|
3
|
+
module Util
|
4
|
+
extend self
|
5
|
+
|
6
|
+
def symbolize_keys(hash)
|
7
|
+
Hash[hash.map { |k, v| [k.to_sym, v] }]
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.logger
|
11
|
+
@logger ||= begin
|
12
|
+
logger = Logger.new(STDOUT)
|
13
|
+
logger.level = Logger::INFO
|
14
|
+
logger.formatter = proc do |severity, datetime, progname, msg|
|
15
|
+
"#{datetime.utc} RedisFailover #{Process.pid} #{severity}: #{msg}\n"
|
16
|
+
end
|
17
|
+
logger
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.logger=(logger)
|
22
|
+
@logger = logger
|
23
|
+
end
|
24
|
+
|
25
|
+
def logger
|
26
|
+
Util.logger
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/redis_failover/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Ryan LeCompte"]
|
6
|
+
gem.email = ["lecompte@gmail.com"]
|
7
|
+
gem.description = %(Redis Failover provides a full automatic master/slave failover solution for Ruby)
|
8
|
+
gem.summary = %(Redis Failover provides a full automatic master/slave failover solution for Ruby)
|
9
|
+
gem.homepage = "http://github.com/ryanlecompte/redis_failover"
|
10
|
+
|
11
|
+
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
12
|
+
gem.files = `git ls-files`.split("\n")
|
13
|
+
gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
14
|
+
gem.name = "redis_failover"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = RedisFailover::VERSION
|
17
|
+
|
18
|
+
gem.add_dependency('redis')
|
19
|
+
gem.add_dependency('redis-namespace')
|
20
|
+
gem.add_dependency('multi_json')
|
21
|
+
gem.add_dependency('sinatra')
|
22
|
+
|
23
|
+
gem.add_development_dependency('rake')
|
24
|
+
gem.add_development_dependency('rspec')
|
25
|
+
end
|
data/spec/cli_spec.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module RedisFailover
|
4
|
+
describe CLI do
|
5
|
+
describe '.parse' do
|
6
|
+
it 'returns empty result for empty options' do
|
7
|
+
CLI.parse({}).should == {}
|
8
|
+
end
|
9
|
+
|
10
|
+
it 'properly parses a server port' do
|
11
|
+
opts = CLI.parse(['-P 2222'])
|
12
|
+
opts.should == {:port => 2222}
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'properly parses redis nodes' do
|
16
|
+
opts = CLI.parse(['-n host1:1,host2:2,host3:3'])
|
17
|
+
opts[:nodes].should == [
|
18
|
+
{:host => 'host1', :port => '1'},
|
19
|
+
{:host => 'host2', :port => '2'},
|
20
|
+
{:host => 'host3', :port => '3'}
|
21
|
+
]
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'properly parses a redis password' do
|
25
|
+
opts = CLI.parse(['-n host:port', '-p redis_pass'])
|
26
|
+
opts[:nodes].should == [{
|
27
|
+
:host => 'host',
|
28
|
+
:port => 'port',
|
29
|
+
:password => 'redis_pass'
|
30
|
+
}]
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'properly parses max node failures' do
|
34
|
+
opts = CLI.parse(['-n host:port', '-p redis_pass', '--max-failures', '1'])
|
35
|
+
opts[:max_failures].should == 1
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|