redis_failover 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|