nogara-redis_failover 0.8.9

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,79 @@
1
+ module RedisFailover
2
+ # NodeWatcher periodically monitors a specific redis node for its availability.
3
+ # NodeWatcher instances periodically report a redis node's current state
4
+ # to the NodeManager for proper handling.
5
+ class NodeWatcher
6
+ include Util
7
+
8
+ # Time to sleep before checking on the monitored node's status.
9
+ WATCHER_SLEEP_TIME = 2
10
+
11
+ # Creates a new instance.
12
+ #
13
+ # @param [NodeManager] manager the node manager
14
+ # @param [Node] node the node to watch
15
+ # @param [Integer] max_failures the max failues before reporting node as down
16
+ def initialize(manager, node, max_failures)
17
+ @manager = manager
18
+ @node = node
19
+ @max_failures = max_failures
20
+ @monitor_thread = nil
21
+ @done = false
22
+ end
23
+
24
+ # Starts the node watcher.
25
+ #
26
+ # @note this method returns immediately and causes monitoring to be
27
+ # performed in a new background thread
28
+ def watch
29
+ @monitor_thread ||= Thread.new { monitor_node }
30
+ self
31
+ end
32
+
33
+ # Performs a graceful shutdown of this watcher.
34
+ def shutdown
35
+ @done = true
36
+ @node.wakeup
37
+ @monitor_thread.join if @monitor_thread
38
+ rescue
39
+ # best effort
40
+ end
41
+
42
+ private
43
+
44
+ # Periodically monitors the redis node and reports state changes to
45
+ # the {RedisFailover::NodeManager}.
46
+ def monitor_node
47
+ failures = 0
48
+
49
+ loop do
50
+ begin
51
+ return if @done
52
+ sleep(WATCHER_SLEEP_TIME)
53
+ @node.ping
54
+ failures = 0
55
+
56
+ if @node.syncing_with_master?
57
+ notify(:syncing)
58
+ else
59
+ notify(:available)
60
+ @node.wait
61
+ end
62
+ rescue NodeUnavailableError
63
+ failures += 1
64
+ if failures >= @max_failures
65
+ notify(:unavailable)
66
+ failures = 0
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ # Notifies the manager of a node's state.
73
+ #
74
+ # @param [Symbol] state the node's state
75
+ def notify(state)
76
+ @manager.notify_state(@node, state)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,28 @@
1
+ module RedisFailover
2
+ # Runner is responsible for bootstrapping the Node Manager.
3
+ class Runner
4
+ # Launches the Node Manager in a background thread.
5
+ #
6
+ # @param [Array] options the command-line options
7
+ # @note this method blocks and does not return until the
8
+ # Node Manager is gracefully stopped
9
+ def self.run(options)
10
+ options = CLI.parse(options)
11
+ @node_manager = NodeManager.new(options)
12
+ trap_signals
13
+ node_manager_thread = Thread.new { @node_manager.start }
14
+ node_manager_thread.join
15
+ end
16
+
17
+ # Traps shutdown signals.
18
+ def self.trap_signals
19
+ [:INT, :TERM].each do |signal|
20
+ trap(signal) do
21
+ Util.logger.info('Shutting down ...')
22
+ @node_manager.shutdown
23
+ exit(0)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,83 @@
1
+ require 'redis_failover/errors'
2
+
3
+ module RedisFailover
4
+ # Common utiilty methods and constants.
5
+ module Util
6
+ extend self
7
+
8
+ # Default node in ZK that contains the current list of available redis nodes.
9
+ DEFAULT_ZNODE_PATH = '/redis_failover_nodes'.freeze
10
+
11
+ # Connectivity errors that the redis (<3.x) client raises.
12
+ REDIS_ERRORS = Errno.constants.map { |c| Errno.const_get(c) }
13
+
14
+ # Connectivity errors that the redis (>3.x) client raises.
15
+ REDIS_ERRORS << Redis::BaseError if Redis.const_defined?("BaseError")
16
+ REDIS_ERRORS.freeze
17
+
18
+ # Full set of errors related to connectivity.
19
+ CONNECTIVITY_ERRORS = [
20
+ RedisFailover::Error,
21
+ ZK::Exceptions::InterruptedSession,
22
+ REDIS_ERRORS
23
+ ].flatten.freeze
24
+
25
+ # Symbolizes the keys of the specified hash.
26
+ #
27
+ # @param [Hash] hash a hash for which keys should be symbolized
28
+ # @return [Hash] a new hash with symbolized keys
29
+ def symbolize_keys(hash)
30
+ Hash[hash.map { |k, v| [k.to_sym, v] }]
31
+ end
32
+
33
+ # Determines if two arrays are different.
34
+ #
35
+ # @param [Array] ary_a the first array
36
+ # @param [Array] ary_b the second array
37
+ # @return [Boolean] true if arrays are different, false otherwise
38
+ def different?(ary_a, ary_b)
39
+ ((ary_a | ary_b) - (ary_a & ary_b)).size > 0
40
+ end
41
+
42
+ # @return [Logger] the logger instance to use
43
+ def self.logger
44
+ @logger ||= begin
45
+ logger = Logger.new(STDOUT)
46
+ logger.level = Logger::INFO
47
+ logger.formatter = proc do |severity, datetime, progname, msg|
48
+ "#{datetime.utc} RedisFailover #{Process.pid} #{severity}: #{msg}\n"
49
+ end
50
+ logger
51
+ end
52
+ end
53
+
54
+ # Sets a new logger to use.
55
+ #
56
+ # @param [Logger] logger a new logger to use
57
+ def self.logger=(logger)
58
+ @logger = logger
59
+ end
60
+
61
+ # @return [Logger] the logger instance to use
62
+ def logger
63
+ Util.logger
64
+ end
65
+
66
+ # Encodes the specified data in JSON format.
67
+ #
68
+ # @param [Object] data the data to encode
69
+ # @return [String] the JSON-encoded data
70
+ def encode(data)
71
+ MultiJson.encode(data)
72
+ end
73
+
74
+ # Decodes the specified JSON data.
75
+ #
76
+ # @param [String] data the JSON data to decode
77
+ # @return [Object] the decoded data
78
+ def decode(data)
79
+ return unless data
80
+ MultiJson.decode(data)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,3 @@
1
+ module RedisFailover
2
+ VERSION = '0.8.9'
3
+ end
Binary file
@@ -0,0 +1,26 @@
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 is a ZooKeeper-based automatic master/slave failover solution for Ruby)
8
+ gem.summary = %(Redis Failover is a ZooKeeper-based 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 = "nogara-redis_failover"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = RedisFailover::VERSION
17
+
18
+ gem.add_dependency('redis', '~> 3')
19
+ gem.add_dependency('redis-namespace')
20
+ gem.add_dependency('multi_json', '~> 1')
21
+ gem.add_dependency('zk', '~> 1.6')
22
+
23
+ gem.add_development_dependency('rake')
24
+ gem.add_development_dependency('rspec')
25
+ gem.add_development_dependency('yard')
26
+ end
data/spec/cli_spec.rb ADDED
@@ -0,0 +1,75 @@
1
+ require 'spec_helper'
2
+
3
+ module RedisFailover
4
+ describe CLI do
5
+ describe '.parse' do
6
+ it 'properly parses redis nodes' do
7
+ opts = CLI.parse(['-n host1:1,host2:2,host3:3', '-z localhost:1111'])
8
+ opts[:nodes].should == [
9
+ {:host => 'host1', :port => '1'},
10
+ {:host => 'host2', :port => '2'},
11
+ {:host => 'host3', :port => '3'}
12
+ ]
13
+ end
14
+
15
+ it 'properly parses ZooKeeper servers' do
16
+ opts = CLI.parse(['-n host1:1,host2:2,host3:3', '-z localhost:1111'])
17
+ opts[:zkservers].should == 'localhost:1111'
18
+ end
19
+
20
+ it 'properly parses a redis password' do
21
+ opts = CLI.parse(['-n host:port', '-z localhost:1111', '-p redis_pass'])
22
+ opts[:nodes].should == [{
23
+ :host => 'host',
24
+ :port => 'port',
25
+ :password => 'redis_pass'
26
+ }]
27
+ end
28
+
29
+ it 'properly parses max node failures' do
30
+ opts = CLI.parse([
31
+ '-n host:port',
32
+ '-z localhost:1111',
33
+ '-p redis_pass',
34
+ '--max-failures',
35
+ '1'])
36
+ opts[:max_failures].should == 1
37
+ end
38
+
39
+ it 'properly parses a chroot' do
40
+ opts = CLI.parse(['-n host:port', '-z localhost:1111', '--with-chroot', '/with/chroot/from/command/line'])
41
+ opts[:nodes].should == [{
42
+ :host => 'host',
43
+ :port => 'port',
44
+ }]
45
+
46
+ opts[:chroot].should == '/with/chroot/from/command/line'
47
+ end
48
+
49
+ it 'properly parses the config file' do
50
+ opts = CLI.parse(['-C', "#{File.dirname(__FILE__)}/support/config/single_environment.yml"])
51
+ opts[:zkservers].should == 'zk01:2181,zk02:2181,zk03:2181'
52
+
53
+ opts = CLI.parse(['-C', "#{File.dirname(__FILE__)}/support/config/multiple_environments.yml", '-E', 'development'])
54
+ opts[:zkservers].should == 'localhost:2181'
55
+
56
+ opts = CLI.parse(['-C', "#{File.dirname(__FILE__)}/support/config/multiple_environments.yml", '-E', 'staging'])
57
+ opts[:zkservers].should == 'zk01:2181,zk02:2181,zk03:2181'
58
+ end
59
+
60
+ it 'properly parses the config file that include chroot' do
61
+ opts = CLI.parse(['-C', "#{File.dirname(__FILE__)}/support/config/single_environment_with_chroot.yml"])
62
+ opts[:zkservers].should == 'zk01:2181,zk02:2181,zk03:2181'
63
+ opts[:chroot].should == '/with/chroot'
64
+
65
+ opts = CLI.parse(['-C', "#{File.dirname(__FILE__)}/support/config/multiple_environments_with_chroot.yml", '-E', 'development'])
66
+ opts[:zkservers].should == 'localhost:2181'
67
+ opts[:chroot].should == '/with/chroot_development'
68
+
69
+ opts = CLI.parse(['-C', "#{File.dirname(__FILE__)}/support/config/multiple_environments_with_chroot.yml", '-E', 'staging'])
70
+ opts[:zkservers].should == 'zk01:2181,zk02:2181,zk03:2181'
71
+ opts[:chroot].should == '/with/chroot_staging'
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,100 @@
1
+ require 'spec_helper'
2
+
3
+ module RedisFailover
4
+ Client::Redis = RedisStub
5
+ class ClientStub < Client
6
+ def current_master
7
+ @master
8
+ end
9
+
10
+ def current_slaves
11
+ @slaves
12
+ end
13
+
14
+ def fetch_nodes
15
+ {
16
+ :master => 'localhost:6379',
17
+ :slaves => ['localhost:1111'],
18
+ :unavailable => []
19
+ }
20
+ end
21
+
22
+ def setup_zk
23
+ @zk = NullObject.new
24
+ update_znode_timestamp
25
+ end
26
+ end
27
+
28
+ describe Client do
29
+ let(:client) { ClientStub.new(:zkservers => 'localhost:9281') }
30
+
31
+ describe '#build_clients' do
32
+ it 'properly parses master' do
33
+ client.current_master.to_s.should == 'localhost:6379'
34
+ end
35
+
36
+ it 'properly parses slaves' do
37
+ client.current_slaves.first.to_s.should == 'localhost:1111'
38
+ end
39
+ end
40
+
41
+ describe '#dispatch' do
42
+ it 'routes write operations to master' do
43
+ called = false
44
+ client.current_master.define_singleton_method(:del) do |*args|
45
+ called = true
46
+ end
47
+ client.del('foo')
48
+ called.should be_true
49
+ end
50
+
51
+ it 'routes read operations to a slave' do
52
+ called = false
53
+ client.current_slaves.first.change_role_to('slave')
54
+ client.current_slaves.first.define_singleton_method(:get) do |*args|
55
+ called = true
56
+ end
57
+ client.get('foo')
58
+ called.should be_true
59
+ end
60
+
61
+ it 'reconnects when node is unavailable' do
62
+ class << client
63
+ attr_reader :reconnected
64
+ def build_clients
65
+ @reconnected = true
66
+ super
67
+ end
68
+
69
+ def fetch_nodes
70
+ @calls ||= 0
71
+ {
72
+ :master => "localhost:222#{@calls += 1}",
73
+ :slaves => ['localhost:1111'],
74
+ :unavailable => []
75
+ }
76
+ end
77
+ end
78
+
79
+ client.current_master.make_unavailable!
80
+ client.del('foo')
81
+ client.reconnected.should be_true
82
+ end
83
+
84
+ it 'properly detects when a node has changed roles' do
85
+ client.current_master.change_role_to('slave')
86
+ expect { client.send(:master) }.to raise_error(InvalidNodeRoleError)
87
+ end
88
+
89
+ it 'raises error for unsupported operations' do
90
+ expect { client.select }.to raise_error(UnsupportedOperationError)
91
+ end
92
+
93
+ it 'attempts ZK reconnect when no communication from Node Manager within certain time window' do
94
+ client.instance_variable_set(:@last_znode_timestamp, Time.at(0))
95
+ client.should_receive(:build_clients)
96
+ client.del('foo')
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,112 @@
1
+ require 'spec_helper'
2
+
3
+ module RedisFailover
4
+ describe NodeManager do
5
+ let(:manager) { NodeManagerStub.new({}) }
6
+
7
+ before(:each) do
8
+ manager.discover_nodes
9
+ end
10
+
11
+ describe '#nodes' do
12
+ it 'returns current master and slave nodes' do
13
+ manager.current_nodes.should == {
14
+ :master => 'master:6379',
15
+ :slaves => ['slave:6379'],
16
+ :unavailable => []
17
+ }
18
+ end
19
+ end
20
+
21
+ describe '#handle_unavailable' do
22
+ context 'slave dies' do
23
+ it 'moves slave to unavailable list' do
24
+ slave = manager.slaves.first
25
+ manager.force_unavailable(slave)
26
+ manager.current_nodes[:unavailable].should include(slave.to_s)
27
+ end
28
+ end
29
+
30
+ context 'master dies' do
31
+ before(:each) do
32
+ @slave = manager.slaves.first
33
+ @master = manager.master
34
+ manager.force_unavailable(@master)
35
+ end
36
+
37
+ it 'promotes slave to master' do
38
+ manager.master.should == @slave
39
+ end
40
+
41
+ it 'moves master to unavailable list' do
42
+ manager.current_nodes[:unavailable].should include(@master.to_s)
43
+ end
44
+ end
45
+ end
46
+
47
+ describe '#handle_available' do
48
+ before(:each) do
49
+ # force to be unavailable first
50
+ @slave = manager.slaves.first
51
+ manager.force_unavailable(@slave)
52
+ end
53
+
54
+ context 'slave node with a master present' do
55
+ it 'removes slave from unavailable list' do
56
+ manager.force_available(@slave)
57
+ manager.current_nodes[:unavailable].should be_empty
58
+ manager.current_nodes[:slaves].should include(@slave.to_s)
59
+ end
60
+
61
+ it 'makes node a slave of new master' do
62
+ manager.master = Node.new(:host => 'foo', :port => '7892')
63
+ manager.force_available(@slave)
64
+ @slave.fetch_info.should == {
65
+ :role => 'slave',
66
+ :master_host => 'foo',
67
+ :master_port => '7892'}
68
+ end
69
+
70
+ it 'does not invoke slaveof operation if master has not changed' do
71
+ @slave.redis.should_not_receive(:slaveof)
72
+ manager.force_available(@slave)
73
+ end
74
+ end
75
+
76
+ context 'slave node with no master present' do
77
+ before(:each) do
78
+ @master = manager.master
79
+ manager.force_unavailable(@master)
80
+ end
81
+
82
+ it 'promotes slave to master' do
83
+ manager.force_available(@slave)
84
+ manager.master.should == @slave
85
+ end
86
+
87
+ it 'slaves list remains empty' do
88
+ manager.current_nodes[:slaves].should be_empty
89
+ end
90
+ end
91
+ end
92
+
93
+ describe '#handle_syncing' do
94
+ context 'prohibits stale reads' do
95
+ it 'adds node to unavailable list' do
96
+ slave = manager.slaves.first
97
+ manager.force_syncing(slave, false)
98
+ manager.current_nodes[:unavailable].should include(slave.to_s)
99
+ end
100
+ end
101
+
102
+ context 'allows stale reads' do
103
+ it 'makes node available' do
104
+ slave = manager.slaves.first
105
+ manager.force_syncing(slave, true)
106
+ manager.current_nodes[:unavailable].should_not include(slave.to_s)
107
+ manager.current_nodes[:slaves].should include(slave.to_s)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end