redis_failover 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/Changes.md CHANGED
@@ -1,9 +1,15 @@
1
- 0.1.0
1
+ 0.2.0
2
2
  -----------
3
-
4
- - First release
3
+ - Added retry support for contacting failover server from client
4
+ - Client now verifies proper master/slave role before attempting operation
5
+ - General edge case cleanup for NodeManager
5
6
 
6
7
  0.1.1
7
8
  -----------
8
9
 
9
- - Fix option parser require
10
+ - Fix option parser require
11
+
12
+ 0.1.0
13
+ -----------
14
+
15
+ - First release
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Redis Failover Client/Server
1
+ # Automatic Redis Failover Client/Server
2
2
 
3
3
  Redis Failover attempts to provides a full automatic master/slave failover solution for Ruby. Redis does not provide
4
4
  an automatic failover capability when configured for master/slave replication. When the master node dies,
@@ -18,7 +18,9 @@ All nodes marked as unreachable will be periodically checked to see if they have
18
18
  If so, the newly reachable nodes will be configured as slaves and brought back into the list of live
19
19
  servers. Note that detection of a node going down should be nearly instantaneous, since the mechanism
20
20
  used to keep tabs on a node is via a blocking Redis BLPOP call (no polling). This call fails nearly
21
- immediately when the node actually goes offline.
21
+ immediately when the node actually goes offline. To avoid false positives (i.e., intermittent flaky
22
+ network interruption), the server will only mark a node as unreachable if it fails to communicate with
23
+ it 3 times (this is configurable via --max-failures, see configuration options below).
22
24
 
23
25
  This gem provides a RedisFailover::Client wrapper that is master/slave aware. The client is configured
24
26
  with a single host/port pair that points to redis failover server. The client will automatically
@@ -6,6 +6,7 @@ module RedisFailover
6
6
  class Client
7
7
  include Util
8
8
 
9
+ RETRY_WAIT_TIME = 3
9
10
  REDIS_ERRORS = Errno.constants.map { |c| Errno.const_get(c) }.freeze
10
11
  REDIS_READ_OPS = Set[
11
12
  :dbsize,
@@ -83,8 +84,8 @@ module RedisFailover
83
84
  @namespace = options[:namespace]
84
85
  @password = options[:password]
85
86
  @retry = options[:retry_failure] || true
86
- @max_retries = options[:max_retries] || 3
87
- @registry_url = "http://#{options[:host]}:#{options[:port]}/redis_servers"
87
+ @max_retries = @retry ? options.fetch(:max_retries, 3) : 0
88
+ @server_url = "http://#{options[:host]}:#{options[:port]}/redis_servers"
88
89
  @redis_servers = nil
89
90
  @master = nil
90
91
  @slaves = []
@@ -117,6 +118,7 @@ module RedisFailover
117
118
 
118
119
  def dispatch(method, *args, &block)
119
120
  tries = 0
121
+
120
122
  begin
121
123
  if REDIS_READ_OPS.include?(method)
122
124
  # send read operations to a slave
@@ -125,14 +127,13 @@ module RedisFailover
125
127
  # direct everything else to master
126
128
  master.send(method, *args, &block)
127
129
  end
128
- rescue NoMasterError, *REDIS_ERRORS
130
+ rescue Error, *REDIS_ERRORS
129
131
  logger.error("No suitable node available for operation `#{method}.`")
130
- sleep(3)
131
132
  build_clients
132
133
 
133
- if @retry && tries < @max_retries
134
+ if tries < @max_retries
134
135
  tries += 1
135
- retry
136
+ sleep(RETRY_WAIT_TIME) && retry
136
137
  end
137
138
 
138
139
  raise
@@ -140,16 +141,26 @@ module RedisFailover
140
141
  end
141
142
 
142
143
  def master
143
- @master or raise NoMasterError
144
+ if @master
145
+ verify_role!(@master, :master)
146
+ return @master
147
+ end
148
+ raise NoMasterError
144
149
  end
145
150
 
146
151
  def slave
147
152
  # pick a slave, if none available fallback to master
148
- @slaves.sample || master
153
+ if slave = @slaves.sample
154
+ verify_role!(slave, :slave)
155
+ return slave
156
+ end
157
+ master
149
158
  end
150
159
 
151
160
  def build_clients
152
161
  @lock.synchronize do
162
+ tries = 0
163
+
153
164
  begin
154
165
  logger.info('Attempting to fetch nodes and build redis clients.')
155
166
  servers = fetch_redis_servers
@@ -160,14 +171,21 @@ module RedisFailover
160
171
  @master = master
161
172
  @slaves = slaves
162
173
  rescue => ex
163
- logger.error("Failed to fetch servers from #{@registry_url} - #{ex.message}")
174
+ logger.error("Failed to fetch servers from #{@server_url} - #{ex.message}")
164
175
  logger.error(ex.backtrace.join("\n"))
176
+
177
+ if tries < @max_retries
178
+ tries += 1
179
+ sleep(RETRY_WAIT_TIME) && retry
180
+ end
181
+
182
+ raise FailoverServerUnreachableError.new(@server_url)
165
183
  end
166
184
  end
167
185
  end
168
186
 
169
187
  def fetch_redis_servers
170
- open(@registry_url) do |io|
188
+ open(@server_url) do |io|
171
189
  servers = symbolize_keys(MultiJson.decode(io))
172
190
  logger.info("Fetched servers: #{servers}")
173
191
  servers
@@ -187,12 +205,24 @@ module RedisFailover
187
205
 
188
206
  def master_info
189
207
  return "none" unless @master
190
- "#{@master.client.host}:#{@master.client.port}"
208
+ name_for(@master)
191
209
  end
192
210
 
193
211
  def slaves_info
194
212
  return "none" if @slaves.empty?
195
- @slaves.map { |s| "#{s.client.host}:#{s.client.port}" }.join(', ')
213
+ @slaves.map { |slave| name_for(slave) }.join(', ')
214
+ end
215
+
216
+ def verify_role!(node, role)
217
+ current_role = node.info['role']
218
+ if current_role.to_sym != role
219
+ raise InvalidNodeRoleError.new(name_for(node), role, current_role)
220
+ end
221
+ role
222
+ end
223
+
224
+ def name_for(node)
225
+ "#{node.client.host}:#{node.client.port}"
196
226
  end
197
227
  end
198
228
  end
@@ -22,4 +22,17 @@ module RedisFailover
22
22
 
23
23
  class NoSlaveError < Error
24
24
  end
25
+
26
+ class FailoverServerUnreachableError < Error
27
+ def initialize(failover_server_url)
28
+ super("Unable to reach #{failover_server_url}")
29
+ end
30
+ end
31
+
32
+ class InvalidNodeRoleError < Error
33
+ def initialize(node, assumed, actual)
34
+ super("Invalid role detected for node #{node}, client thought " +
35
+ "it was a #{assumed}, but it's now a #{actual}")
36
+ end
37
+ end
25
38
  end
@@ -60,6 +60,7 @@ module RedisFailover
60
60
  return if @unreachable.include?(node)
61
61
  logger.info("Handling unreachable node: #{node}")
62
62
 
63
+ @unreachable << node
63
64
  # find a new master if this node was a master
64
65
  if node == @master
65
66
  logger.info("Demoting currently unreachable master #{node}.")
@@ -67,8 +68,6 @@ module RedisFailover
67
68
  else
68
69
  @slaves.delete(node)
69
70
  end
70
-
71
- @unreachable << node
72
71
  end
73
72
 
74
73
  def handle_reachable(node)
@@ -76,33 +75,32 @@ module RedisFailover
76
75
  return if @master == node || @slaves.include?(node)
77
76
  logger.info("Handling reachable node: #{node}")
78
77
 
79
- @unreachable.delete(node)
80
- @slaves << node
81
78
  if @master
82
79
  # master already exists, make a slave
83
80
  node.make_slave!(@master)
81
+ @slaves << node
84
82
  else
85
83
  # no master exists, make this the new master
86
- promote_new_master
84
+ promote_new_master(node)
87
85
  end
86
+
87
+ @unreachable.delete(node)
88
88
  end
89
89
 
90
- def promote_new_master
90
+ def promote_new_master(node = nil)
91
91
  @master = nil
92
92
 
93
- if @slaves.empty?
94
- logger.error('Failed to promote a new master since no slaves available.')
93
+ # make a specific node or slave the new master
94
+ candidate = node || @slaves.pop
95
+ unless candidate
96
+ logger.error('Failed to promote a new master since no candidate available.')
95
97
  return
96
98
  end
97
99
 
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.")
100
+ candidate.make_master!
101
+ @master = candidate
102
+ redirect_slaves_to_master
103
+ logger.info("Successfully promoted #{candidate} to master.")
106
104
  end
107
105
 
108
106
  def parse_nodes
@@ -118,7 +116,7 @@ module RedisFailover
118
116
 
119
117
  def spawn_watchers
120
118
  @watchers = [@master, *@slaves].map do |node|
121
- NodeWatcher.new(self, node, @options[:max_failures] || 3)
119
+ NodeWatcher.new(self, node, @options[:max_failures] || 3)
122
120
  end
123
121
  @watchers.each(&:watch)
124
122
  end
@@ -136,14 +134,13 @@ module RedisFailover
136
134
  end
137
135
  end
138
136
 
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
137
+ def redirect_slaves_to_master
138
+ # redirect each slave to the current master
142
139
  @slaves.each do |slave|
143
140
  begin
144
141
  slave.make_slave!(@master)
145
142
  rescue NodeUnreachableError
146
- # will eventually be handled by watcher
143
+ # will also be detected by watcher
147
144
  end
148
145
  end
149
146
  end
@@ -1,3 +1,3 @@
1
1
  module RedisFailover
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -40,6 +40,7 @@ module RedisFailover
40
40
  end
41
41
 
42
42
  it 'routes read operations to a slave' do
43
+ client.current_slaves.first.change_role_to('slave')
43
44
  client.current_slaves.first.should_receive(:get)
44
45
  client.get('foo')
45
46
  end
@@ -57,6 +58,17 @@ module RedisFailover
57
58
  client.del('foo')
58
59
  client.reconnected.should be_true
59
60
  end
61
+
62
+ it 'fails hard when the failover server is unreachable' do
63
+ expect do
64
+ Client.new(:host => 'foo', :port => 123445)
65
+ end.to raise_error(FailoverServerUnreachableError)
66
+ end
67
+
68
+ it 'properly detects when a node has changed roles' do
69
+ client.current_master.change_role_to('slave')
70
+ expect { client.send(:master) }.to raise_error(InvalidNodeRoleError)
71
+ end
60
72
  end
61
73
  end
62
74
  end
@@ -5,7 +5,7 @@ module RedisFailover
5
5
  class RedisStub
6
6
  class Proxy
7
7
  def initialize(queue, opts = {})
8
- @info = {:role => 'master'}
8
+ @info = {'role' => 'master'}
9
9
  @queue = queue
10
10
  end
11
11
 
@@ -24,9 +24,9 @@ module RedisFailover
24
24
 
25
25
  def slaveof(host, port)
26
26
  if host == 'no' && port == 'one'
27
- @info[:role] = 'master'
27
+ @info['role'] = 'master'
28
28
  else
29
- @info[:role] = 'slave'
29
+ @info['role'] = 'slave'
30
30
  end
31
31
  end
32
32
 
@@ -37,6 +37,10 @@ module RedisFailover
37
37
  def ping
38
38
  'pong'
39
39
  end
40
+
41
+ def change_role_to(role)
42
+ @info['role'] = role
43
+ end
40
44
  end
41
45
 
42
46
  attr_reader :host, :port, :reachable
@@ -56,6 +60,10 @@ module RedisFailover
56
60
  end
57
61
  end
58
62
 
63
+ def change_role_to(role)
64
+ @proxy.change_role_to(role)
65
+ end
66
+
59
67
  def make_reachable!
60
68
  @reachable = true
61
69
  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.1.1
4
+ version: 0.2.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-12 00:00:00.000000000 Z
12
+ date: 2012-04-13 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: redis
16
- requirement: &70104404926580 !ruby/object:Gem::Requirement
16
+ requirement: &70198326575220 !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: *70104404926580
24
+ version_requirements: *70198326575220
25
25
  - !ruby/object:Gem::Dependency
26
26
  name: redis-namespace
27
- requirement: &70104404926140 !ruby/object:Gem::Requirement
27
+ requirement: &70198326574780 !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: *70104404926140
35
+ version_requirements: *70198326574780
36
36
  - !ruby/object:Gem::Dependency
37
37
  name: multi_json
38
- requirement: &70104404925720 !ruby/object:Gem::Requirement
38
+ requirement: &70198326574360 !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: *70104404925720
46
+ version_requirements: *70198326574360
47
47
  - !ruby/object:Gem::Dependency
48
48
  name: sinatra
49
- requirement: &70104404925300 !ruby/object:Gem::Requirement
49
+ requirement: &70198326573940 !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: *70104404925300
57
+ version_requirements: *70198326573940
58
58
  - !ruby/object:Gem::Dependency
59
59
  name: rake
60
- requirement: &70104404924880 !ruby/object:Gem::Requirement
60
+ requirement: &70198326573520 !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: *70104404924880
68
+ version_requirements: *70198326573520
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: rspec
71
- requirement: &70104404924460 !ruby/object:Gem::Requirement
71
+ requirement: &70198326573100 !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: *70104404924460
79
+ version_requirements: *70198326573100
80
80
  description: Redis Failover provides a full automatic master/slave failover solution
81
81
  for Ruby
82
82
  email:
@@ -128,7 +128,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
128
128
  version: '0'
129
129
  segments:
130
130
  - 0
131
- hash: 2359035733939003378
131
+ hash: -200241163739368802
132
132
  required_rubygems_version: !ruby/object:Gem::Requirement
133
133
  none: false
134
134
  requirements:
@@ -137,7 +137,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
137
137
  version: '0'
138
138
  segments:
139
139
  - 0
140
- hash: 2359035733939003378
140
+ hash: -200241163739368802
141
141
  requirements: []
142
142
  rubyforge_project:
143
143
  rubygems_version: 1.8.16