redis_failover 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
data/Changes.md CHANGED
@@ -1,8 +1,17 @@
1
+ 0.4.0
2
+ -----------
3
+ - No longer force newly available slaves to master if already slaves of that master
4
+ - Honor a node's slave-serve-stale-data configuration option; do not mark a sync-with-master-in-progress slave as available if its slave-serve-stale-data is disabled
5
+ - Change reachable/unreachable wording to available/unavailable
6
+ - Added node reconciliation, i.e. if a node comes back up, make sure that the node manager and the node agree on current role
7
+ - More efficient use of redis client connections
8
+ - Raise proper error for unsupported operations (i.e., those that don't make sense for a failover client)
9
+ - Properly handle any hanging node operations in the failover server
10
+
1
11
  0.3.0
2
12
  -----------
3
13
  - Integrated travis-ci
4
- - Added background monitor to client for proactively detecting
5
- changes to current set of redis nodes
14
+ - Added background monitor to client for proactively detecting changes to current set of redis nodes
6
15
 
7
16
  0.2.0
8
17
  -----------
data/README.md CHANGED
@@ -13,15 +13,15 @@ master, and all reads go to one of the N configured slaves.
13
13
  This gem attempts to address both the server and client problems. A redis failover server runs as a background
14
14
  daemon and monitors all of your configured master/slave nodes. When the server starts up, it
15
15
  automatically discovers who is the master and who are the slaves. Watchers are setup for each of
16
- the redis nodes. As soon as a node is detected as being offline, it will be moved to an "unreachable" state.
16
+ the redis nodes. As soon as a node is detected as being offline, it will be moved to an "unavailable" state.
17
17
  If the node that went offline was the master, then one of the slaves will be promoted as the new master.
18
18
  All existing slaves will be automatically reconfigured to point to the new master for replication.
19
- All nodes marked as unreachable will be periodically checked to see if they have been brought back online.
20
- If so, the newly reachable nodes will be configured as slaves and brought back into the list of live
19
+ All nodes marked as unavailable will be periodically checked to see if they have been brought back online.
20
+ If so, the newly available nodes will be configured as slaves and brought back into the list of live
21
21
  servers. Note that detection of a node going down should be nearly instantaneous, since the mechanism
22
22
  used to keep tabs on a node is via a blocking Redis BLPOP call (no polling). This call fails nearly
23
23
  immediately when the node actually goes offline. To avoid false positives (i.e., intermittent flaky
24
- network interruption), the server will only mark a node as unreachable if it fails to communicate with
24
+ network interruption), the server will only mark a node as unavailable if it fails to communicate with
25
25
  it 3 times (this is configurable via --max-failures, see configuration options below).
26
26
 
27
27
  This gem provides a RedisFailover::Client wrapper that is master/slave aware. The client is configured
@@ -55,7 +55,7 @@ following options:
55
55
  -P, --port port Server port
56
56
  -p, --password password Redis password
57
57
  -n, --nodes nodes Comma-separated redis host:port pairs
58
- --max-failures count Max failures before server marks node unreachable (default 3)
58
+ --max-failures count Max failures before server marks node unavailable (default 3)
59
59
  -h, --help Display all options
60
60
 
61
61
  To start the server for a simple master/slave configuration, use the following:
@@ -87,6 +87,25 @@ The full set of options that can be passed to RedisFailover::Client are:
87
87
  :retry_failure - indicate if failures should be retried (default true)
88
88
  :max_retries - max retries for a failure (default 3)
89
89
 
90
+ ## Requirements
91
+
92
+ redis_failover is actively tested against MRI 1.9.2/1.9.3. Other rubies may work, although I don't actively test against them. 1.8 is not supported.
93
+
94
+ ## Considerations
95
+
96
+ Note that by default the failover server will mark slaves that are currently syncing with their master as "available" based on the configuration value set for "slave-serve-stale-data" in redis.conf. By default this value is set to "yes" in the configuration, which means that slaves still syncing with their master will be available for servicing read requests. If you don't want this behavior, just set "slave-serve-stale-data" to "no" in your redis.conf file.
97
+
98
+ ## Limitations
99
+
100
+ The redis_failover gem currently has limitations. It currently does not gracefully handle network partitions. In cases where
101
+ the network splits, it is possible that more than one master could exist until the failover server sees all of the nodes again.
102
+ If the failover client gets split from the failover server, it's also possible that it could be talking to a stale master. This would get corrected once the client could successfully reach the failover server again to fetch the latest set of master/slave nodes. This is a limitation that I hope to address in a future release. The gem can not guarantee data consistencies until this is addressed.
103
+
104
+ ## TODO
105
+
106
+ - Integrate with ZooKeeper for full reliability / data consistency.
107
+ - Rework specs to work against a set of real Redis nodes as opposed to stubs.
108
+
90
109
  ## Resources
91
110
 
92
111
  To learn more about Redis master/slave replication, see the [Redis documentation](http://redis.io/topics/replication).
@@ -1,6 +1,7 @@
1
1
  require 'redis'
2
2
  require 'thread'
3
3
  require 'logger'
4
+ require 'timeout'
4
5
  require 'optparse'
5
6
  require 'multi_json'
6
7
  require 'securerandom'
@@ -24,7 +24,7 @@ module RedisFailover
24
24
  end
25
25
 
26
26
  opts.on('--max-failures count',
27
- 'Max failures before server marks node unreachable (default 3)') do |max|
27
+ 'Max failures before server marks node unavailable (default 3)') do |max|
28
28
  options[:max_failures] = Integer(max)
29
29
  end
30
30
 
@@ -9,7 +9,6 @@ module RedisFailover
9
9
  RETRY_WAIT_TIME = 3
10
10
  REDIS_ERRORS = Errno.constants.map { |c| Errno.const_get(c) }.freeze
11
11
  REDIS_READ_OPS = Set[
12
- :dbsize,
13
12
  :echo,
14
13
  :exists,
15
14
  :get,
@@ -22,26 +21,21 @@ module RedisFailover
22
21
  :hlen,
23
22
  :hmget,
24
23
  :hvals,
25
- :info,
26
24
  :keys,
27
- :lastsave,
28
25
  :lindex,
29
26
  :llen,
30
27
  :lrange,
31
28
  :mapped_hmget,
32
29
  :mapped_mget,
33
30
  :mget,
34
- :ping,
35
31
  :scard,
36
32
  :sdiff,
37
- :select,
38
33
  :sinter,
39
34
  :sismember,
40
35
  :smembers,
41
36
  :srandmember,
42
37
  :strlen,
43
38
  :sunion,
44
- :ttl,
45
39
  :type,
46
40
  :zcard,
47
41
  :zcount,
@@ -54,6 +48,12 @@ module RedisFailover
54
48
  :zscore
55
49
  ].freeze
56
50
 
51
+ UNSUPPORTED_OPS = Set[
52
+ :select,
53
+ :ttl,
54
+ :dbsize,
55
+ ].freeze
56
+
57
57
  # Performance optimization: to avoid unnecessary method_missing calls,
58
58
  # we proactively define methods that dispatch to the underlying redis
59
59
  # calls.
@@ -88,7 +88,6 @@ module RedisFailover
88
88
  @server_url = "http://#{options[:host]}:#{options[:port]}/redis_servers"
89
89
  @master = nil
90
90
  @slaves = []
91
- @lock = Mutex.new
92
91
  build_clients
93
92
  start_background_monitor
94
93
  end
@@ -117,6 +116,7 @@ module RedisFailover
117
116
  end
118
117
 
119
118
  def dispatch(method, *args, &block)
119
+ verify_supported!(method)
120
120
  tries = 0
121
121
 
122
122
  begin
@@ -159,32 +159,30 @@ module RedisFailover
159
159
  end
160
160
 
161
161
  def build_clients
162
- @lock.synchronize do
163
- tries = 0
164
-
165
- begin
166
- logger.info('Checking for new redis nodes.')
167
- nodes = fetch_nodes
168
- return unless nodes_changed?(nodes)
169
-
170
- logger.info('Node change detected, rebuilding clients.')
171
- master = new_clients_for(nodes[:master]).first if nodes[:master]
172
- slaves = new_clients_for(*nodes[:slaves])
173
-
174
- # once clients are successfully created, swap the references
175
- @master = master
176
- @slaves = slaves
177
- rescue => ex
178
- logger.error("Failed to fetch nodes from #{@server_url} - #{ex.message}")
179
- logger.error(ex.backtrace.join("\n"))
180
-
181
- if tries < @max_retries
182
- tries += 1
183
- sleep(RETRY_WAIT_TIME) && retry
184
- end
162
+ tries = 0
185
163
 
186
- raise FailoverServerUnreachableError.new(@server_url)
164
+ begin
165
+ logger.info('Checking for new redis nodes.')
166
+ nodes = fetch_nodes
167
+ return unless nodes_changed?(nodes)
168
+
169
+ logger.info('Node change detected, rebuilding clients.')
170
+ master = new_clients_for(nodes[:master]).first if nodes[:master]
171
+ slaves = new_clients_for(*nodes[:slaves])
172
+
173
+ # once clients are successfully created, swap the references
174
+ @master = master
175
+ @slaves = slaves
176
+ rescue => ex
177
+ logger.error("Failed to fetch nodes from #{@server_url} - #{ex.message}")
178
+ logger.error(ex.backtrace.join("\n"))
179
+
180
+ if tries < @max_retries
181
+ tries += 1
182
+ sleep(RETRY_WAIT_TIME) && retry
187
183
  end
184
+
185
+ raise FailoverServerUnavailableError.new(@server_url)
188
186
  end
189
187
  end
190
188
 
@@ -224,6 +222,12 @@ module RedisFailover
224
222
  role
225
223
  end
226
224
 
225
+ def verify_supported!(method)
226
+ if UNSUPPORTED_OPS.include?(method)
227
+ raise UnsupportedOperationError.new(method)
228
+ end
229
+ end
230
+
227
231
  def addresses_for(nodes)
228
232
  nodes.map { |node| address_for(node) }
229
233
  end
@@ -1,5 +1,10 @@
1
1
  module RedisFailover
2
2
  class Error < StandardError
3
+ attr_reader :original
4
+ def initialize(msg = nil, original = $!)
5
+ super(msg)
6
+ @original = original
7
+ end
3
8
  end
4
9
 
5
10
  class InvalidNodeError < Error
@@ -11,7 +16,7 @@ module RedisFailover
11
16
  end
12
17
  end
13
18
 
14
- class NodeUnreachableError < Error
19
+ class NodeUnavailableError < Error
15
20
  def initialize(node)
16
21
  super("Node: #{node}")
17
22
  end
@@ -23,9 +28,9 @@ module RedisFailover
23
28
  class NoSlaveError < Error
24
29
  end
25
30
 
26
- class FailoverServerUnreachableError < Error
31
+ class FailoverServerUnavailableError < Error
27
32
  def initialize(failover_server_url)
28
- super("Unable to reach #{failover_server_url}")
33
+ super("Unable to access #{failover_server_url}")
29
34
  end
30
35
  end
31
36
 
@@ -35,4 +40,10 @@ module RedisFailover
35
40
  "it was a #{assumed}, but it's now a #{actual}")
36
41
  end
37
42
  end
43
+
44
+ class UnsupportedOperationError < Error
45
+ def initialize(operation)
46
+ super("Operation `#{operation}` is currently unsupported")
47
+ end
48
+ end
38
49
  end
@@ -3,6 +3,8 @@ module RedisFailover
3
3
  class Node
4
4
  include Util
5
5
 
6
+ MAX_OP_WAIT_TIME = 5
7
+
6
8
  attr_reader :host, :port
7
9
 
8
10
  def initialize(options = {})
@@ -11,12 +13,6 @@ module RedisFailover
11
13
  @password = options[:password]
12
14
  end
13
15
 
14
- def ping
15
- perform_operation do
16
- redis.ping
17
- end
18
- end
19
-
20
16
  def master?
21
17
  role == 'master'
22
18
  end
@@ -25,29 +21,48 @@ module RedisFailover
25
21
  !master?
26
22
  end
27
23
 
28
- def wait_until_unreachable
29
- perform_operation do
30
- redis.blpop(wait_key, 0)
24
+ def slave_of?(master)
25
+ current_master == master
26
+ end
27
+
28
+ def current_master
29
+ info = fetch_info
30
+ return unless info[:role] == 'slave'
31
+ Node.new(:host => info[:master_host], :port => info[:master_port].to_i)
32
+ end
33
+
34
+ # Waits until something interesting happens. If the connection
35
+ # with this node dies, the blpop call will raise an error. If
36
+ # the blpop call returns without error, then this will be due to
37
+ # a graceful shutdown signaled by #wakeup or a timeout.
38
+ def wait
39
+ perform_operation do |redis|
40
+ redis.blpop(wait_key, MAX_OP_WAIT_TIME - 3)
31
41
  redis.del(wait_key)
32
42
  end
33
43
  end
34
44
 
35
- def stop_waiting
36
- perform_operation do
45
+ def wakeup
46
+ perform_operation do |redis|
37
47
  redis.lpush(wait_key, '1')
38
48
  end
39
49
  end
40
50
 
41
51
  def make_slave!(master)
42
- perform_operation do
43
- redis.slaveof(master.host, master.port)
52
+ perform_operation do |redis|
53
+ unless slave_of?(master)
54
+ redis.slaveof(master.host, master.port)
55
+ wakeup
56
+ end
44
57
  end
45
58
  end
46
59
 
47
60
  def make_master!
48
- perform_operation do
49
- # yes, this is a real redis operation!
50
- redis.slaveof('no', 'one')
61
+ perform_operation do |redis|
62
+ unless master?
63
+ redis.slaveof('no', 'one')
64
+ wakeup
65
+ end
51
66
  end
52
67
  end
53
68
 
@@ -70,32 +85,55 @@ module RedisFailover
70
85
  to_s.hash
71
86
  end
72
87
 
73
- private
88
+ def fetch_info
89
+ perform_operation do |redis|
90
+ symbolize_keys(redis.info)
91
+ end
92
+ end
93
+ alias_method :ping, :fetch_info
74
94
 
75
- def role
76
- fetch_info[:role]
95
+ def prohibits_stale_reads?
96
+ perform_operation do |redis|
97
+ redis.config('get', 'slave-serve-stale-data').last == 'no'
98
+ end
77
99
  end
78
100
 
79
- def fetch_info
80
- perform_operation do
81
- symbolize_keys(redis.info)
101
+ def syncing_with_master?
102
+ perform_operation do |redis|
103
+ fetch_info[:master_sync_in_progress] == '1'
82
104
  end
83
105
  end
84
106
 
107
+ private
108
+
109
+ def role
110
+ fetch_info[:role]
111
+ end
112
+
85
113
  def wait_key
86
114
  @wait_key ||= "_redis_failover_#{SecureRandom.hex(32)}"
87
115
  end
88
116
 
89
- def redis
117
+ def new_client
90
118
  Redis.new(:host => @host, :password => @password, :port => @port)
91
- rescue
92
- raise NodeUnreachableError.new(self)
93
119
  end
94
120
 
95
121
  def perform_operation
96
- yield
122
+ redis = nil
123
+ Timeout.timeout(MAX_OP_WAIT_TIME) do
124
+ redis = new_client
125
+ yield redis
126
+ end
97
127
  rescue
98
- raise NodeUnreachableError.new(self)
128
+ raise NodeUnavailableError.new(self)
129
+ ensure
130
+ if redis
131
+ begin
132
+ redis.client.disconnect
133
+ rescue
134
+ raise NodeUnavailableError.new(self)
135
+ end
136
+ end
99
137
  end
100
138
  end
101
139
  end
@@ -6,7 +6,7 @@ module RedisFailover
6
6
  def initialize(options)
7
7
  @options = options
8
8
  @master, @slaves = parse_nodes
9
- @unreachable = []
9
+ @unavailable = []
10
10
  @queue = Queue.new
11
11
  @lock = Mutex.new
12
12
  end
@@ -25,7 +25,7 @@ module RedisFailover
25
25
  {
26
26
  :master => @master ? @master.to_s : nil,
27
27
  :slaves => @slaves.map(&:to_s),
28
- :unreachable => @unreachable.map(&:to_s)
28
+ :unavailable => @unavailable.map(&:to_s)
29
29
  }
30
30
  end
31
31
  end
@@ -42,12 +42,13 @@ module RedisFailover
42
42
  node, state = state_change
43
43
  begin
44
44
  case state
45
- when :unreachable then handle_unreachable(node)
46
- when :reachable then handle_reachable(node)
45
+ when :unavailable then handle_unavailable(node)
46
+ when :available then handle_available(node)
47
+ when :syncing then handle_syncing(node)
47
48
  else raise InvalidNodeStateError.new(node, state)
48
49
  end
49
- rescue NodeUnreachableError
50
- # node suddenly became unreachable, silently
50
+ rescue NodeUnavailableError
51
+ # node suddenly became unavailable, silently
51
52
  # handle since the watcher will take care of
52
53
  # keeping track of the node
53
54
  end
@@ -55,25 +56,27 @@ module RedisFailover
55
56
  end
56
57
  end
57
58
 
58
- def handle_unreachable(node)
59
+ def handle_unavailable(node)
59
60
  # no-op if we already know about this node
60
- return if @unreachable.include?(node)
61
- logger.info("Handling unreachable node: #{node}")
61
+ return if @unavailable.include?(node)
62
+ logger.info("Handling unavailable node: #{node}")
62
63
 
63
- @unreachable << node
64
+ @unavailable << node
64
65
  # find a new master if this node was a master
65
66
  if node == @master
66
- logger.info("Demoting currently unreachable master #{node}.")
67
+ logger.info("Demoting currently unavailable master #{node}.")
67
68
  promote_new_master
68
69
  else
69
70
  @slaves.delete(node)
70
71
  end
71
72
  end
72
73
 
73
- def handle_reachable(node)
74
+ def handle_available(node)
75
+ reconcile(node)
76
+
74
77
  # no-op if we already know about this node
75
78
  return if @master == node || @slaves.include?(node)
76
- logger.info("Handling reachable node: #{node}")
79
+ logger.info("Handling available node: #{node}")
77
80
 
78
81
  if @master
79
82
  # master already exists, make a slave
@@ -84,7 +87,20 @@ module RedisFailover
84
87
  promote_new_master(node)
85
88
  end
86
89
 
87
- @unreachable.delete(node)
90
+ @unavailable.delete(node)
91
+ end
92
+
93
+ def handle_syncing(node)
94
+ reconcile(node)
95
+
96
+ if node.prohibits_stale_reads?
97
+ logger.info("Node #{node} not ready yet, still syncing with master.")
98
+ @unavailable << node
99
+ return
100
+ end
101
+
102
+ # otherwise, we can use this node
103
+ handle_available(node)
88
104
  end
89
105
 
90
106
  def promote_new_master(node = nil)
@@ -104,7 +120,7 @@ module RedisFailover
104
120
  end
105
121
 
106
122
  def parse_nodes
107
- nodes = @options[:nodes].map { |opts| Node.new(opts) }
123
+ nodes = @options[:nodes].map { |opts| Node.new(opts) }.uniq
108
124
  raise NoMasterError unless master = find_master(nodes)
109
125
  slaves = nodes - [master]
110
126
 
@@ -127,7 +143,7 @@ module RedisFailover
127
143
  nodes.find do |node|
128
144
  begin
129
145
  node.master?
130
- rescue NodeUnreachableError
146
+ rescue NodeUnavailableError
131
147
  # will eventually be handled by watcher
132
148
  false
133
149
  end
@@ -139,10 +155,30 @@ module RedisFailover
139
155
  @slaves.each do |slave|
140
156
  begin
141
157
  slave.make_slave!(@master)
142
- rescue NodeUnreachableError
158
+ rescue NodeUnavailableError
143
159
  # will also be detected by watcher
144
160
  end
145
161
  end
146
162
  end
163
+
164
+ # It's possible that a newly available node may have been restarted
165
+ # and completely lost its dynamically set run-time role by the node
166
+ # manager. This method ensures that the node resumes its role as
167
+ # determined by the manager.
168
+ def reconcile(node)
169
+ return if @master == node && node.master?
170
+ return if @master && node.slave_of?(@master)
171
+
172
+ if @master == node && !node.master?
173
+ # we think the node is a master, but the node doesn't
174
+ node.make_master!
175
+ return
176
+ end
177
+
178
+ # verify that node is a slave for the current master
179
+ if @master && !node.slave_of?(@master)
180
+ node.make_slave!(@master)
181
+ end
182
+ end
147
183
  end
148
184
  end
@@ -1,6 +1,10 @@
1
1
  module RedisFailover
2
- # Watches a specific redis node for its reachability.
2
+ # Watches a specific redis node for its availability.
3
3
  class NodeWatcher
4
+ include Util
5
+
6
+ WATCHER_SLEEP_TIME = 3
7
+
4
8
  def initialize(manager, node, max_failures)
5
9
  @manager = manager
6
10
  @node = node
@@ -16,7 +20,7 @@ module RedisFailover
16
20
 
17
21
  def shutdown
18
22
  @done = true
19
- @node.stop_waiting
23
+ @node.wakeup
20
24
  @monitor_thread.join if @monitor_thread
21
25
  rescue
22
26
  # best effort
@@ -27,20 +31,31 @@ module RedisFailover
27
31
  def monitor_node
28
32
  failures = 0
29
33
 
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)
34
+ loop do
35
+ begin
36
+ return if @done
37
+ sleep(WATCHER_SLEEP_TIME)
38
+ @node.ping
40
39
  failures = 0
40
+
41
+ if @node.syncing_with_master?
42
+ notify(:syncing)
43
+ else
44
+ notify(:available)
45
+ @node.wait
46
+ end
47
+ rescue NodeUnavailableError
48
+ failures += 1
49
+ if failures >= @max_failures
50
+ notify(:unavailable)
51
+ failures = 0
52
+ end
41
53
  end
42
- sleep(3) && retry
43
54
  end
44
55
  end
56
+
57
+ def notify(state)
58
+ @manager.notify_state_change(@node, state)
59
+ end
45
60
  end
46
61
  end
@@ -1,3 +1,3 @@
1
1
  module RedisFailover
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
@@ -15,7 +15,7 @@ module RedisFailover
15
15
  {
16
16
  :master => 'localhost:6379',
17
17
  :slaves => ['localhost:1111'],
18
- :unreachable => []
18
+ :unavailable => []
19
19
  }
20
20
  end
21
21
  end
@@ -57,7 +57,7 @@ module RedisFailover
57
57
  client.get('foo')
58
58
  end
59
59
 
60
- it 'reconnects with redis failover server when node is unreachable' do
60
+ it 'reconnects with redis failover server when node is unavailable' do
61
61
  class << client
62
62
  attr_reader :reconnected
63
63
  def build_clients
@@ -70,26 +70,30 @@ module RedisFailover
70
70
  {
71
71
  :master => "localhost:222#{@calls += 1}",
72
72
  :slaves => ['localhost:1111'],
73
- :unreachable => []
73
+ :unavailable => []
74
74
  }
75
75
  end
76
76
  end
77
77
 
78
- client.current_master.make_unreachable!
78
+ client.current_master.make_unavailable!
79
79
  client.del('foo')
80
80
  client.reconnected.should be_true
81
81
  end
82
82
 
83
- it 'fails hard when the failover server is unreachable' do
83
+ it 'fails hard when the failover server is unavailable' do
84
84
  expect do
85
85
  Client.new(:host => 'foo', :port => 123445)
86
- end.to raise_error(FailoverServerUnreachableError)
86
+ end.to raise_error(FailoverServerUnavailableError)
87
87
  end
88
88
 
89
89
  it 'properly detects when a node has changed roles' do
90
90
  client.current_master.change_role_to('slave')
91
91
  expect { client.send(:master) }.to raise_error(InvalidNodeRoleError)
92
92
  end
93
+
94
+ it 'raises error for unsupported operations' do
95
+ expect { client.select }.to raise_error(UnsupportedOperationError)
96
+ end
93
97
  end
94
98
  end
95
99
  end
@@ -9,17 +9,17 @@ module RedisFailover
9
9
  manager.nodes.should == {
10
10
  :master => 'master:6379',
11
11
  :slaves => ['slave:6379'],
12
- :unreachable => []
12
+ :unavailable => []
13
13
  }
14
14
  end
15
15
  end
16
16
 
17
- describe '#handle_unreachable' do
17
+ describe '#handle_unavailable' do
18
18
  context 'slave dies' do
19
- it 'moves slave to unreachable list' do
19
+ it 'moves slave to unavailable list' do
20
20
  slave = manager.slaves.first
21
- manager.force_unreachable(slave)
22
- manager.nodes[:unreachable].should include(slave.to_s)
21
+ manager.force_unavailable(slave)
22
+ manager.nodes[:unavailable].should include(slave.to_s)
23
23
  end
24
24
  end
25
25
 
@@ -27,43 +27,57 @@ module RedisFailover
27
27
  before(:each) do
28
28
  @slave = manager.slaves.first
29
29
  @master = manager.master
30
- manager.force_unreachable(@master)
30
+ manager.force_unavailable(@master)
31
31
  end
32
32
 
33
33
  it 'promotes slave to master' do
34
34
  manager.master.should == @slave
35
35
  end
36
36
 
37
- it 'moves master to unreachable list' do
38
- manager.nodes[:unreachable].should include(@master.to_s)
37
+ it 'moves master to unavailable list' do
38
+ manager.nodes[:unavailable].should include(@master.to_s)
39
39
  end
40
40
  end
41
41
  end
42
42
 
43
- describe '#handle_reachable' do
43
+ describe '#handle_available' do
44
44
  before(:each) do
45
- # force to be unreachable first
45
+ # force to be unavailable first
46
46
  @slave = manager.slaves.first
47
- manager.force_unreachable(@slave)
47
+ manager.force_unavailable(@slave)
48
48
  end
49
49
 
50
50
  context 'slave node with a master present' do
51
- it 'removes slave from unreachable list' do
52
- manager.force_reachable(@slave)
53
- manager.nodes[:unreachable].should be_empty
51
+ it 'removes slave from unavailable list' do
52
+ manager.force_available(@slave)
53
+ manager.nodes[:unavailable].should be_empty
54
54
  manager.nodes[:slaves].should include(@slave.to_s)
55
55
  end
56
+
57
+ it 'makes node a slave of new master' do
58
+ manager.master = Node.new(:host => 'foo', :port => '7892')
59
+ manager.force_available(@slave)
60
+ @slave.fetch_info.should == {
61
+ :role => 'slave',
62
+ :master_host => 'foo',
63
+ :master_port => '7892'}
64
+ end
65
+
66
+ it 'does not invoke slaveof operation if master has not changed' do
67
+ @slave.redis.should_not_receive(:slaveof)
68
+ manager.force_available(@slave)
69
+ end
56
70
  end
57
71
 
58
72
  context 'slave node with no master present' do
59
73
  before(:each) do
60
74
  @master = manager.master
61
- manager.force_unreachable(@master)
75
+ manager.force_unavailable(@master)
62
76
  end
63
77
 
64
78
  it 'promotes slave to master' do
65
79
  manager.master.should be_nil
66
- manager.force_reachable(@slave)
80
+ manager.force_available(@slave)
67
81
  manager.master.should == @slave
68
82
  end
69
83
 
@@ -72,5 +86,24 @@ module RedisFailover
72
86
  end
73
87
  end
74
88
  end
89
+
90
+ describe '#handle_syncing' do
91
+ context 'prohibits stale reads' do
92
+ it 'adds node to unavailable list' do
93
+ slave = manager.slaves.first
94
+ manager.force_syncing(slave, false)
95
+ manager.nodes[:unavailable].should include(slave.to_s)
96
+ end
97
+ end
98
+
99
+ context 'allows stale reads' do
100
+ it 'makes node available' do
101
+ slave = manager.slaves.first
102
+ manager.force_syncing(slave, true)
103
+ manager.nodes[:unavailable].should_not include(slave.to_s)
104
+ manager.nodes[:slaves].should include(slave.to_s)
105
+ end
106
+ end
107
+ end
75
108
  end
76
109
  end
@@ -20,13 +20,13 @@ module RedisFailover
20
20
  end
21
21
 
22
22
  describe '#ping' do
23
- it 'responds properly if node is reachable' do
23
+ it 'responds properly if node is available' do
24
24
  expect { node.ping }.to_not raise_error
25
25
  end
26
26
 
27
- it 'responds properly if node is unreachable' do
28
- node.redis.make_unreachable!
29
- expect { node.ping }.to raise_error(NodeUnreachableError)
27
+ it 'responds properly if node is unavailable' do
28
+ node.redis.make_unavailable!
29
+ expect { node.ping }.to raise_error(NodeUnavailableError)
30
30
  end
31
31
  end
32
32
 
@@ -53,24 +53,32 @@ module RedisFailover
53
53
  end
54
54
  end
55
55
 
56
- describe '#wait_until_unreachable' do
56
+ describe '#wait' do
57
57
  it 'should wait until node dies' do
58
- thread = Thread.new { node.wait_until_unreachable }
58
+ thread = Thread.new { node.wait }
59
59
  thread.should be_alive
60
- node.redis.make_unreachable!
60
+ node.redis.make_unavailable!
61
61
  expect { thread.value }.to raise_error
62
62
  end
63
63
  end
64
64
 
65
- describe '#stop_waiting' do
65
+ describe '#wakeup' do
66
66
  it 'should gracefully stop waiting' do
67
- thread = Thread.new { node.wait_until_unreachable }
67
+ thread = Thread.new { node.wait }
68
68
  thread.should be_alive
69
- node.stop_waiting
69
+ node.wakeup
70
70
  sleep 0.2
71
71
  thread.should_not be_alive
72
72
  thread.value.should be_nil
73
73
  end
74
74
  end
75
+
76
+ describe '#perform_operation' do
77
+ it 'raises error for any operation that hangs for too long' do
78
+ expect do
79
+ node.send(:perform_operation) { 1_000_000.times { sleep 0.1 } }
80
+ end.to raise_error(NodeUnavailableError)
81
+ end
82
+ end
75
83
  end
76
84
  end
@@ -20,23 +20,38 @@ module RedisFailover
20
20
  let(:node) { Node.new(:host => 'host', :port => 123).extend(RedisStubSupport) }
21
21
 
22
22
  describe '#watch' do
23
- it 'properly informs manager of unreachable node' do
24
- watcher = NodeWatcher.new(node_manager, node, 1)
25
- watcher.watch
26
- sleep(3)
27
- node.redis.make_unreachable!
28
- sleep(3)
29
- watcher.shutdown
30
- node_manager.state_for(node).should == :unreachable
23
+ context 'node is not syncing with master' do
24
+ it 'properly informs manager of unavailable node' do
25
+ watcher = NodeWatcher.new(node_manager, node, 1)
26
+ watcher.watch
27
+ sleep(3)
28
+ node.redis.make_unavailable!
29
+ sleep(3)
30
+ watcher.shutdown
31
+ node_manager.state_for(node).should == :unavailable
32
+ end
33
+
34
+ it 'properly informs manager of available node' do
35
+ node_manager.notify_state_change(node, :unavailable)
36
+ watcher = NodeWatcher.new(node_manager, node, 1)
37
+ watcher.watch
38
+ sleep(3)
39
+ watcher.shutdown
40
+ node_manager.state_for(node).should == :available
41
+ end
31
42
  end
32
43
 
33
- it 'properly informs manager of reachable node' do
34
- node_manager.notify_state_change(node, :unreachable)
35
- watcher = NodeWatcher.new(node_manager, node, 1)
36
- watcher.watch
37
- sleep(3)
38
- watcher.shutdown
39
- node_manager.state_for(node).should == :reachable
44
+ context 'node is syncing with master' do
45
+ it 'properly informs manager of syncing node' do
46
+ node_manager.notify_state_change(node, :unavailable)
47
+ node.redis.slaveof('masterhost', 9876)
48
+ node.redis.force_sync_with_master(true)
49
+ watcher = NodeWatcher.new(node_manager, node, 1)
50
+ watcher.watch
51
+ sleep(3)
52
+ watcher.shutdown
53
+ node_manager.state_for(node).should == :syncing
54
+ end
40
55
  end
41
56
  end
42
57
  end
@@ -1,5 +1,7 @@
1
1
  module RedisFailover
2
2
  class NodeManagerStub < NodeManager
3
+ attr_accessor :master
4
+
3
5
  def parse_nodes
4
6
  master = Node.new(:host => 'master')
5
7
  slave = Node.new(:host => 'slave')
@@ -9,10 +11,6 @@ module RedisFailover
9
11
  [master, [slave]]
10
12
  end
11
13
 
12
- def master
13
- @master
14
- end
15
-
16
14
  def slaves
17
15
  @slaves
18
16
  end
@@ -26,17 +24,24 @@ module RedisFailover
26
24
  @thread.value
27
25
  end
28
26
 
29
- def force_unreachable(node)
27
+ def force_unavailable(node)
28
+ start_processing
29
+ node.redis.make_unavailable!
30
+ notify_state_change(node, :unavailable)
31
+ stop_processing
32
+ end
33
+
34
+ def force_available(node)
30
35
  start_processing
31
- node.redis.make_unreachable!
32
- notify_state_change(node, :unreachable)
36
+ node.redis.make_available!
37
+ notify_state_change(node, :available)
33
38
  stop_processing
34
39
  end
35
40
 
36
- def force_reachable(node)
41
+ def force_syncing(node, serve_stale_reads)
37
42
  start_processing
38
- node.redis.make_reachable!
39
- notify_state_change(node, :reachable)
43
+ node.redis.force_sync_with_master(serve_stale_reads)
44
+ notify_state_change(node, :syncing)
40
45
  stop_processing
41
46
  end
42
47
  end
@@ -6,6 +6,7 @@ module RedisFailover
6
6
  class Proxy
7
7
  def initialize(queue, opts = {})
8
8
  @info = {'role' => 'master'}
9
+ @config = {'slave-serve-stale-data' => 'yes'}
9
10
  @queue = queue
10
11
  end
11
12
 
@@ -25,8 +26,12 @@ module RedisFailover
25
26
  def slaveof(host, port)
26
27
  if host == 'no' && port == 'one'
27
28
  @info['role'] = 'master'
29
+ @info.delete('master_host')
30
+ @info.delete('master_port')
28
31
  else
29
32
  @info['role'] = 'slave'
33
+ @info['master_host'] = host
34
+ @info['master_port'] = port.to_s
30
35
  end
31
36
  end
32
37
 
@@ -34,26 +39,35 @@ module RedisFailover
34
39
  @info.dup
35
40
  end
36
41
 
37
- def ping
38
- 'pong'
39
- end
40
-
41
42
  def change_role_to(role)
42
43
  @info['role'] = role
43
44
  end
45
+
46
+ def config(action, attribute)
47
+ [action, @config[attribute]]
48
+ end
49
+
50
+ def force_sync_with_master(serve_stale_reads)
51
+ @config['slave-serve-stale-data'] = serve_stale_reads ? 'yes' : 'no'
52
+ @info['master_sync_in_progress'] = '1'
53
+ end
54
+
55
+ def force_sync_done
56
+ @info['master_sync_in_progress'] = '0'
57
+ end
44
58
  end
45
59
 
46
- attr_reader :host, :port, :reachable
60
+ attr_reader :host, :port, :available
47
61
  def initialize(opts = {})
48
62
  @host = opts[:host]
49
63
  @port = Integer(opts[:port])
50
64
  @queue = Queue.new
51
65
  @proxy = Proxy.new(@queue, opts)
52
- @reachable = true
66
+ @available = true
53
67
  end
54
68
 
55
69
  def method_missing(method, *args, &block)
56
- if @reachable
70
+ if @available
57
71
  @proxy.send(method, *args, &block)
58
72
  else
59
73
  raise Errno::ECONNREFUSED
@@ -64,13 +78,13 @@ module RedisFailover
64
78
  @proxy.change_role_to(role)
65
79
  end
66
80
 
67
- def make_reachable!
68
- @reachable = true
81
+ def make_available!
82
+ @available = true
69
83
  end
70
84
 
71
- def make_unreachable!
85
+ def make_unavailable!
72
86
  @queue << Errno::ECONNREFUSED
73
- @reachable = false
87
+ @available = false
74
88
  end
75
89
 
76
90
  def to_s
@@ -86,5 +100,6 @@ module RedisFailover
86
100
  def redis
87
101
  @redis ||= RedisStub.new(:host => @host, :port => @port)
88
102
  end
103
+ alias_method :new_client, :redis
89
104
  end
90
105
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis_failover
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,11 +9,11 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2012-04-14 00:00:00.000000000 Z
12
+ date: 2012-04-15 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: redis
16
- requirement: &70202974273640 !ruby/object:Gem::Requirement
16
+ requirement: &70210559246440 !ruby/object:Gem::Requirement
17
17
  none: false
18
18
  requirements:
19
19
  - - ! '>='
@@ -21,10 +21,10 @@ dependencies:
21
21
  version: '0'
22
22
  type: :runtime
23
23
  prerelease: false
24
- version_requirements: *70202974273640
24
+ version_requirements: *70210559246440
25
25
  - !ruby/object:Gem::Dependency
26
26
  name: redis-namespace
27
- requirement: &70202974273200 !ruby/object:Gem::Requirement
27
+ requirement: &70210559246000 !ruby/object:Gem::Requirement
28
28
  none: false
29
29
  requirements:
30
30
  - - ! '>='
@@ -32,10 +32,10 @@ dependencies:
32
32
  version: '0'
33
33
  type: :runtime
34
34
  prerelease: false
35
- version_requirements: *70202974273200
35
+ version_requirements: *70210559246000
36
36
  - !ruby/object:Gem::Dependency
37
37
  name: multi_json
38
- requirement: &70202974272780 !ruby/object:Gem::Requirement
38
+ requirement: &70210559245580 !ruby/object:Gem::Requirement
39
39
  none: false
40
40
  requirements:
41
41
  - - ! '>='
@@ -43,10 +43,10 @@ dependencies:
43
43
  version: '0'
44
44
  type: :runtime
45
45
  prerelease: false
46
- version_requirements: *70202974272780
46
+ version_requirements: *70210559245580
47
47
  - !ruby/object:Gem::Dependency
48
48
  name: sinatra
49
- requirement: &70202974272360 !ruby/object:Gem::Requirement
49
+ requirement: &70210559245160 !ruby/object:Gem::Requirement
50
50
  none: false
51
51
  requirements:
52
52
  - - ! '>='
@@ -54,10 +54,10 @@ dependencies:
54
54
  version: '0'
55
55
  type: :runtime
56
56
  prerelease: false
57
- version_requirements: *70202974272360
57
+ version_requirements: *70210559245160
58
58
  - !ruby/object:Gem::Dependency
59
59
  name: rake
60
- requirement: &70202974271940 !ruby/object:Gem::Requirement
60
+ requirement: &70210559244740 !ruby/object:Gem::Requirement
61
61
  none: false
62
62
  requirements:
63
63
  - - ! '>='
@@ -65,10 +65,10 @@ dependencies:
65
65
  version: '0'
66
66
  type: :development
67
67
  prerelease: false
68
- version_requirements: *70202974271940
68
+ version_requirements: *70210559244740
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: rspec
71
- requirement: &70202974271520 !ruby/object:Gem::Requirement
71
+ requirement: &70210559244320 !ruby/object:Gem::Requirement
72
72
  none: false
73
73
  requirements:
74
74
  - - ! '>='
@@ -76,7 +76,7 @@ dependencies:
76
76
  version: '0'
77
77
  type: :development
78
78
  prerelease: false
79
- version_requirements: *70202974271520
79
+ version_requirements: *70210559244320
80
80
  description: Redis Failover provides a full automatic master/slave failover solution
81
81
  for Ruby
82
82
  email:
@@ -129,7 +129,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
129
129
  version: '0'
130
130
  segments:
131
131
  - 0
132
- hash: -1781006209108187327
132
+ hash: -4081812195470581448
133
133
  required_rubygems_version: !ruby/object:Gem::Requirement
134
134
  none: false
135
135
  requirements:
@@ -138,7 +138,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
138
138
  version: '0'
139
139
  segments:
140
140
  - 0
141
- hash: -1781006209108187327
141
+ hash: -4081812195470581448
142
142
  requirements: []
143
143
  rubyforge_project:
144
144
  rubygems_version: 1.8.16