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,119 @@
1
+ module RedisFailover
2
+ # Parses server command-line arguments.
3
+ class CLI
4
+ # Parses the source of options.
5
+ #
6
+ # @param [Array] source the command-line options to parse
7
+ # @return [Hash] the parsed options
8
+ def self.parse(source)
9
+ options = {}
10
+ parser = OptionParser.new do |opts|
11
+ opts.banner = "Usage: redis_node_manager [OPTIONS]"
12
+ opts.separator ""
13
+ opts.separator "Specific options:"
14
+
15
+ opts.on('-n', '--nodes NODES',
16
+ 'Comma-separated redis host:port pairs') do |nodes|
17
+ options[:nodes] = nodes
18
+ end
19
+
20
+ opts.on('-z', '--zkservers SERVERS',
21
+ 'Comma-separated ZooKeeper host:port pairs') do |servers|
22
+ options[:zkservers] = servers
23
+ end
24
+
25
+ opts.on('-p', '--password PASSWORD', 'Redis password') do |password|
26
+ options[:password] = password
27
+ end
28
+
29
+ opts.on('--znode-path PATH',
30
+ 'Znode path override for storing redis server list') do |path|
31
+ options[:znode_path] = path
32
+ end
33
+
34
+ opts.on('--max-failures COUNT',
35
+ 'Max failures before manager marks node unavailable') do |max|
36
+ options[:max_failures] = Integer(max)
37
+ end
38
+
39
+ opts.on('-C', '--config PATH', 'Path to YAML config file') do |file|
40
+ options[:config_file] = file
41
+ end
42
+
43
+ opts.on('--with-chroot ROOT', 'Path to ZooKeepers chroot') do |chroot|
44
+ options[:chroot] = chroot
45
+ end
46
+
47
+ opts.on('-E', '--environment ENV', 'Config environment to use') do |config_env|
48
+ options[:config_environment] = config_env
49
+ end
50
+
51
+ opts.on('-h', '--help', 'Display all options') do
52
+ puts opts
53
+ exit
54
+ end
55
+ end
56
+
57
+ parser.parse(source)
58
+ if config_file = options[:config_file]
59
+ options = from_file(config_file, options[:config_environment])
60
+ end
61
+
62
+ if required_options_missing?(options)
63
+ puts parser
64
+ exit
65
+ end
66
+
67
+ prepare(options)
68
+ end
69
+
70
+ # @return [Boolean] true if required options missing, false otherwise
71
+ def self.required_options_missing?(options)
72
+ return true if options.empty?
73
+ return true unless options.values_at(:nodes, :zkservers).all?
74
+ false
75
+ end
76
+
77
+ # Parses options from a YAML file.
78
+ #
79
+ # @param [String] file the filename
80
+ # @params [String] env the environment
81
+ # @return [Hash] the parsed options
82
+ def self.from_file(file, env = nil)
83
+ unless File.exists?(file)
84
+ raise ArgumentError, "File #{file} can't be found"
85
+ end
86
+ options = YAML.load_file(file)
87
+
88
+ if env
89
+ options = options.fetch(env.to_sym) do
90
+ raise ArgumentError, "Environment #{env} can't be found in config"
91
+ end
92
+ end
93
+
94
+ options[:nodes] = options[:nodes].join(',')
95
+ options[:zkservers] = options[:zkservers].join(',')
96
+
97
+ options
98
+ end
99
+
100
+ # Prepares the options for the rest of the system.
101
+ #
102
+ # @param [Hash] options the options to prepare
103
+ # @return [Hash] the prepared options
104
+ def self.prepare(options)
105
+ options.each_value { |v| v.strip! if v.respond_to?(:strip!) }
106
+ # turns 'host1:port,host2:port' => [{:host => host, :port => port}, ...]
107
+ options[:nodes] = options[:nodes].split(',').map do |node|
108
+ Hash[[:host, :port].zip(node.split(':'))]
109
+ end
110
+
111
+ # assume password is same for all redis nodes
112
+ if password = options[:password]
113
+ options[:nodes].each { |opts| opts.update(:password => password) }
114
+ end
115
+
116
+ options
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,441 @@
1
+ module RedisFailover
2
+ # Redis failover-aware client. RedisFailover::Client is a wrapper over a set
3
+ # of underlying redis clients, which means all normal redis operations can be
4
+ # performed on an instance of this class. The class only requires a set of
5
+ # ZooKeeper server addresses to function properly. The client will automatically
6
+ # retry failed operations, and handle failover to a new master. The client
7
+ # registers and listens for watcher events from the Node Manager. When these
8
+ # events are received, the client fetches the latest set of redis nodes from
9
+ # ZooKeeper and rebuilds its internal Redis clients appropriately.
10
+ # RedisFailover::Client also directs write operations to the master, and all
11
+ # read operations to the slaves.
12
+ #
13
+ # @example Usage
14
+ # zk_servers = 'localhost:2181,localhost:2182,localhost:2183'
15
+ # client = RedisFailover::Client.new(:zkservers => zk_servers)
16
+ # client.set('foo', 1) # will be directed to master
17
+ # client.get('foo') # will be directed to a slave
18
+ #
19
+ class Client
20
+ include Util
21
+
22
+ # Maximum allowed elapsed time between notifications from the Node Manager.
23
+ # When this timeout is reached, the client will raise a NoNodeManagerError
24
+ # and purge its internal redis clients.
25
+ ZNODE_UPDATE_TIMEOUT = 9
26
+
27
+ # Amount of time to sleep before retrying a failed operation.
28
+ RETRY_WAIT_TIME = 3
29
+
30
+ # Redis read operations that are automatically dispatched to slaves. Any
31
+ # operation not listed here will be dispatched to the master.
32
+ REDIS_READ_OPS = Set[
33
+ :echo,
34
+ :exists,
35
+ :get,
36
+ :getbit,
37
+ :getrange,
38
+ :hexists,
39
+ :hget,
40
+ :hgetall,
41
+ :hkeys,
42
+ :hlen,
43
+ :hmget,
44
+ :hvals,
45
+ :keys,
46
+ :lindex,
47
+ :llen,
48
+ :lrange,
49
+ :mapped_hmget,
50
+ :mapped_mget,
51
+ :mget,
52
+ :scard,
53
+ :sdiff,
54
+ :sinter,
55
+ :sismember,
56
+ :smembers,
57
+ :srandmember,
58
+ :strlen,
59
+ :sunion,
60
+ :type,
61
+ :zcard,
62
+ :zcount,
63
+ :zrange,
64
+ :zrangebyscore,
65
+ :zrank,
66
+ :zrevrange,
67
+ :zrevrangebyscore,
68
+ :zrevrank,
69
+ :zscore
70
+ ].freeze
71
+
72
+ # Unsupported Redis operations. These don't make sense in a client
73
+ # that abstracts the master/slave servers.
74
+ UNSUPPORTED_OPS = Set[:select, :dbsize].freeze
75
+
76
+ # Performance optimization: to avoid unnecessary method_missing calls,
77
+ # we proactively define methods that dispatch to the underlying redis
78
+ # calls.
79
+ Redis.public_instance_methods(false).each do |method|
80
+ define_method(method) do |*args, &block|
81
+ dispatch(method, *args, &block)
82
+ end
83
+ end
84
+
85
+ # Creates a new failover redis client.
86
+ #
87
+ # @param [Hash] options the options used to initialize the client instance
88
+ # @option options [String] :zkservers comma-separated ZooKeeper host:port
89
+ # @option options [String] :znode_path znode path override for redis nodes
90
+ # @option options [String] :password password for redis nodes
91
+ # @option options [String] :db database to use for redis nodes
92
+ # @option options [String] :namespace namespace for redis nodes
93
+ # @option options [Logger] :logger logger override
94
+ # @option options [Boolean] :retry_failure indicates if failures are retried
95
+ # @option options [Integer] :max_retries max retries for a failure
96
+ # @return [RedisFailover::Client]
97
+ def initialize(options = {})
98
+ Util.logger = options[:logger] if options[:logger]
99
+ @zkservers = options.fetch(:zkservers) { raise ArgumentError, ':zkservers required'}
100
+ @znode = options[:znode_path] || Util::DEFAULT_ZNODE_PATH
101
+ @namespace = options[:namespace]
102
+ @password = options[:password]
103
+ @db = options[:db]
104
+ @retry = options[:retry_failure] || true
105
+ @max_retries = @retry ? options.fetch(:max_retries, 3) : 0
106
+ @master = nil
107
+ @slaves = []
108
+ @node_addresses = {}
109
+ @lock = Monitor.new
110
+ @current_client_key = "current-client-#{self.object_id}"
111
+ setup_zk
112
+ build_clients
113
+ end
114
+
115
+ # Dispatches redis operations to master/slaves.
116
+ def method_missing(method, *args, &block)
117
+ if redis_operation?(method)
118
+ dispatch(method, *args, &block)
119
+ else
120
+ super
121
+ end
122
+ end
123
+
124
+ # Determines whether or not an unknown method can be handled.
125
+ #
126
+ # @param [Symbol] method the method to check
127
+ # @param [Boolean] include_private determines if private methods are checked
128
+ # @return [Boolean] indicates if the method can be handled
129
+ def respond_to_missing?(method, include_private)
130
+ redis_operation?(method) || super
131
+ end
132
+
133
+ # @return [String] a string representation of the client
134
+ def inspect
135
+ "#<RedisFailover::Client (master: #{master_name}, slaves: #{slave_names})>"
136
+ end
137
+ alias_method :to_s, :inspect
138
+
139
+ # Force a manual failover to a new server. A specific server can be specified
140
+ # via options. If no options are passed, a random slave will be selected as
141
+ # the candidate for the new master.
142
+ #
143
+ # @param [Hash] options the options used for manual failover
144
+ # @option options [String] :host the host of the failover candidate
145
+ # @option options [String] :port the port of the failover candidate
146
+ def manual_failover(options = {})
147
+ ManualFailover.new(@zk, options).perform
148
+ self
149
+ end
150
+
151
+ # Gracefully performs a shutdown of this client. This method is
152
+ # mostly useful when the client is used in a forking environment.
153
+ # When a fork occurs, you can call this method in an after_fork hook,
154
+ # and then create a new instance of the client. The underlying
155
+ # ZooKeeper client and redis clients will be closed.
156
+ def shutdown
157
+ @zk.close! if @zk
158
+ @zk = nil
159
+ purge_clients
160
+ end
161
+
162
+ # Reconnect will first perform a shutdown of the underlying redis clients.
163
+ # Next, it attempts to reopen the ZooKeeper client and re-create the redis
164
+ # clients after it fetches the most up-to-date list from ZooKeeper.
165
+ def reconnect
166
+ purge_clients
167
+ @zk ? @zk.reopen : setup_zk
168
+ build_clients
169
+ end
170
+
171
+ private
172
+
173
+ # Sets up the underlying ZooKeeper connection.
174
+ def setup_zk
175
+ @zk = ZK.new(@zkservers)
176
+ @zk.watcher.register(@znode) { |event| handle_zk_event(event) }
177
+ @zk.on_expired_session { purge_clients }
178
+ @zk.on_connected { @zk.stat(@znode, :watch => true) }
179
+ @zk.stat(@znode, :watch => true)
180
+ update_znode_timestamp
181
+ end
182
+
183
+ # Handles a ZK event.
184
+ #
185
+ # @param [ZK::Event] event the ZK event to handle
186
+ def handle_zk_event(event)
187
+ update_znode_timestamp
188
+ if event.node_created? || event.node_changed?
189
+ build_clients
190
+ elsif event.node_deleted?
191
+ purge_clients
192
+ @zk.stat(@znode, :watch => true)
193
+ else
194
+ logger.error("Unknown ZK node event: #{event.inspect}")
195
+ end
196
+ end
197
+
198
+ # Determines if a method is a known redis operation.
199
+ #
200
+ # @param [Symbol] method the method to check
201
+ # @return [Boolean] true if redis operation, false otherwise
202
+ def redis_operation?(method)
203
+ Redis.public_instance_methods(false).include?(method)
204
+ end
205
+
206
+ # Dispatches a redis operation to a master or slave.
207
+ #
208
+ # @param [Symbol] method the method to dispatch
209
+ # @param [Array] args the arguments to pass to the method
210
+ # @param [Proc] block an optional block to pass to the method
211
+ # @return [Object] the result of dispatching the command
212
+ def dispatch(method, *args, &block)
213
+ unless recently_heard_from_node_manager?
214
+ build_clients
215
+ end
216
+
217
+ verify_supported!(method)
218
+ tries = 0
219
+ begin
220
+ client_for(method).send(method, *args, &block)
221
+ rescue *CONNECTIVITY_ERRORS => ex
222
+ logger.error("Error while handling `#{method}` - #{ex.inspect}")
223
+ logger.error(ex.backtrace.join("\n"))
224
+
225
+ if tries < @max_retries
226
+ tries += 1
227
+ build_clients
228
+ sleep(RETRY_WAIT_TIME)
229
+ retry
230
+ end
231
+ raise
232
+ ensure
233
+ free_client
234
+ end
235
+ end
236
+
237
+ # Returns the currently known master.
238
+ #
239
+ # @return [Redis] the Redis client for the current master
240
+ # @raise [NoMasterError] if no master is available
241
+ def master
242
+ if master = @lock.synchronize { @master }
243
+ verify_role!(master, :master)
244
+ return master
245
+ end
246
+ raise NoMasterError
247
+ end
248
+
249
+ # Returns a random slave from the list of known slaves.
250
+ #
251
+ # @note If there are no slaves, the master is returned.
252
+ # @return [Redis] the Redis client for the slave or master
253
+ # @raise [NoMasterError] if no master fallback is available
254
+ def slave
255
+ # pick a slave, if none available fallback to master
256
+ if slave = @lock.synchronize { @slaves.sample }
257
+ verify_role!(slave, :slave)
258
+ return slave
259
+ end
260
+ master
261
+ end
262
+
263
+ # Builds the Redis clients for the currently known master/slaves.
264
+ # The current master/slaves are fetched via ZooKeeper.
265
+ def build_clients
266
+ @lock.synchronize do
267
+ begin
268
+ nodes = fetch_nodes
269
+ return unless nodes_changed?(nodes)
270
+
271
+ purge_clients
272
+ logger.info("Building new clients for nodes #{nodes}")
273
+ new_master = new_clients_for(nodes[:master]).first if nodes[:master]
274
+ new_slaves = new_clients_for(*nodes[:slaves])
275
+ @master = new_master
276
+ @slaves = new_slaves
277
+ rescue
278
+ purge_clients
279
+ raise
280
+ end
281
+ end
282
+ end
283
+
284
+ # Fetches the known redis nodes from ZooKeeper.
285
+ #
286
+ # @return [Hash] the known master/slave redis servers
287
+ def fetch_nodes
288
+ data = @zk.get(@znode, :watch => true).first
289
+ nodes = symbolize_keys(decode(data))
290
+ logger.debug("Fetched nodes: #{nodes}")
291
+
292
+ nodes
293
+ rescue Zookeeper::Exceptions::InheritedConnectionError => ex
294
+ logger.debug { "Caught #{ex.class} '#{ex.message}' - reopening ZK client" }
295
+ @zk.reopen
296
+ retry
297
+ end
298
+
299
+ # Builds new Redis clients for the specified nodes.
300
+ #
301
+ # @param [Array<String>] nodes the array of redis host:port pairs
302
+ # @return [Array<Redis>] the array of corresponding Redis clients
303
+ def new_clients_for(*nodes)
304
+ nodes.map do |node|
305
+ host, port = node.split(':')
306
+ opts = {:host => host, :port => port}
307
+ opts.update(:db => @db) if @db
308
+ opts.update(:password => @password) if @password
309
+ client = Redis.new(opts)
310
+ if @namespace
311
+ client = Redis::Namespace.new(@namespace, :redis => client)
312
+ end
313
+ @node_addresses[client] = node
314
+ client
315
+ end
316
+ end
317
+
318
+ # @return [String] a friendly name for current master
319
+ def master_name
320
+ address_for(@master) || 'none'
321
+ end
322
+
323
+ # @return [Array<String>] friendly names for current slaves
324
+ def slave_names
325
+ return 'none' if @slaves.empty?
326
+ addresses_for(@slaves).join(', ')
327
+ end
328
+
329
+ # Verifies the actual role for a redis node.
330
+ #
331
+ # @param [Redis] node the redis node to check
332
+ # @param [Symbol] role the role to verify
333
+ # @return [Symbol] the verified role
334
+ # @raise [InvalidNodeRoleError] if the role is invalid
335
+ def verify_role!(node, role)
336
+ current_role = node.info['role']
337
+ if current_role.to_sym != role
338
+ raise InvalidNodeRoleError.new(address_for(node), role, current_role)
339
+ end
340
+ role
341
+ end
342
+
343
+ # Ensures that the method is supported.
344
+ #
345
+ # @raise [UnsupportedOperationError] if the operation isn't supported
346
+ def verify_supported!(method)
347
+ if UNSUPPORTED_OPS.include?(method)
348
+ raise UnsupportedOperationError.new(method)
349
+ end
350
+ end
351
+
352
+ # Returns node addresses.
353
+ #
354
+ # @param [Array<Redis>] nodes the redis clients
355
+ # @return [Array<String>] the addresses for the nodes
356
+ def addresses_for(nodes)
357
+ nodes.map { |node| address_for(node) }
358
+ end
359
+
360
+ # Returns a node address.
361
+ #
362
+ # @param [Redis] node a redis client
363
+ # @return [String] the address for the node
364
+ def address_for(node)
365
+ return unless node
366
+ @node_addresses[node]
367
+ end
368
+
369
+ # Determines if the currently known redis servers is different
370
+ # from the nodes returned by ZooKeeper.
371
+ #
372
+ # @param [Array<String>] new_nodes the new redis nodes
373
+ # @return [Boolean] true if nodes are different, false otherwise
374
+ def nodes_changed?(new_nodes)
375
+ return true if address_for(@master) != new_nodes[:master]
376
+ return true if different?(addresses_for(@slaves), new_nodes[:slaves])
377
+ false
378
+ end
379
+
380
+ # Disconnects one or more redis clients.
381
+ #
382
+ # @param [Array<Redis>] redis_clients the redis clients
383
+ def disconnect(*redis_clients)
384
+ redis_clients.each do |conn|
385
+ if conn
386
+ begin
387
+ conn.client.disconnect
388
+ rescue
389
+ # best effort
390
+ end
391
+ end
392
+ end
393
+ end
394
+
395
+ # Disconnects current redis clients.
396
+ def purge_clients
397
+ @lock.synchronize do
398
+ logger.info("Purging current redis clients")
399
+ disconnect(@master, *@slaves)
400
+ @master = nil
401
+ @slaves = []
402
+ @node_addresses = {}
403
+ end
404
+ end
405
+
406
+ # Updates timestamp when an event is received by the Node Manager.
407
+ def update_znode_timestamp
408
+ @last_znode_timestamp = Time.now
409
+ end
410
+
411
+ # @return [Boolean] indicates if we recently heard from the Node Manager
412
+ def recently_heard_from_node_manager?
413
+ return false unless @last_znode_timestamp
414
+ Time.now - @last_znode_timestamp <= ZNODE_UPDATE_TIMEOUT
415
+ end
416
+
417
+ # Acquires a client to use for the specified operation.
418
+ #
419
+ # @param [Symbol] method the method for which to retrieve a client
420
+ # @return [Redis] a redis client to use
421
+ # @note
422
+ # This method stores a stack of clients used to handle the case
423
+ # where the same RedisFailover::Client instance is referenced by
424
+ # nested blocks (e.g., block passed to multi).
425
+ def client_for(method)
426
+ # stack = Thread.current[@current_client_key] ||= []
427
+ # client = stack.last || (REDIS_READ_OPS.include?(method) ? slave : master)
428
+ # stack << client
429
+ # client
430
+ master
431
+ end
432
+
433
+ # Pops a client from the thread-local client stack.
434
+ def free_client
435
+ if stack = Thread.current[@current_client_key]
436
+ stack.pop
437
+ end
438
+ nil
439
+ end
440
+ end
441
+ end