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.
@@ -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