redis_failover 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,3 @@
1
+ module RedisFailover
2
+ VERSION = "0.1.0"
3
+ 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