Skip to content

Commit e07eefc

Browse files
committed
Merge branch 'sentinel' of https://github.com/antirez/redis-rb into antirez-sentinel
2 parents b1a619d + 0ef2fc5 commit e07eefc

File tree

4 files changed

+243
-2
lines changed

4 files changed

+243
-2
lines changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,27 @@ available on [rdoc.info][rdoc].
8585

8686
[rdoc]: http://rdoc.info/github/redis/redis-rb/
8787

88+
## Sentinel support
89+
90+
Redis-rb is able to optionally fetch the current master address using
91+
[Redis Sentinel](http://redis.io/topics/sentinel). The new
92+
[Sentinel handshake protocol](http://redis.io/topics/sentinel-clients)
93+
is supported, so redis-rb when used with Sentinel will automatically connect
94+
to the new master after a failover, assuming you use a Recent version of
95+
Redis 2.8.
96+
97+
To connect using Sentinel, use:
98+
99+
```ruby
100+
Sentinels = [{:host => "127.0.0.1", :port => 26380},
101+
{:host => "127.0.0.1", :port => 26381}]
102+
r = Redis.new(:url => "sentinel://mymaster", :sentinels => Sentinels, :role => :master)
103+
```
104+
105+
* The master name, that identifies a group of Redis instances composed of a master and one or more slaves, is specified in the url parameter (`mymaster` in the example).
106+
* It is possible to optionally provide a role. The allowed roles are `master` and `slave`, and the default is `master`. When the role is `slave` redis-rb will try to fetch a list of slaves and will connect to a random slave.
107+
* When using the Sentinel support you need to specify a list of Sentinels to connect to. The list does not need to enumerate all your Sentinel instances, but a few so that if one is down redis-rb will try the next one. The client is able to remember the last Sentinel that was able to reply correctly and will use it for the next requests.
108+
88109
## Storing objects
89110

90111
Redis only stores strings as values. If you want to store an object, you

examples/consistency.rb

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# This file implements a simple consistency test for Redis-rb (or any other
2+
# Redis environment if you pass a different client object) where a client
3+
# writes to the database using INCR in order to increment keys, but actively
4+
# remember the value the key should have. Before every write a read is performed
5+
# to check if the value in the database matches the value expected.
6+
#
7+
# In this way this program can check for lost writes, or acknowledged writes
8+
# that were executed.
9+
#
10+
# Copyright (C) 2013-2014 Salvatore Sanfilippo <[email protected]>
11+
#
12+
# Permission is hereby granted, free of charge, to any person obtaining
13+
# a copy of this software and associated documentation files (the
14+
# "Software"), to deal in the Software without restriction, including
15+
# without limitation the rights to use, copy, modify, merge, publish,
16+
# distribute, sublicense, and/or sell copies of the Software, and to
17+
# permit persons to whom the Software is furnished to do so, subject to
18+
# the following conditions:
19+
#
20+
# The above copyright notice and this permission notice shall be
21+
# included in all copies or substantial portions of the Software.
22+
#
23+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
24+
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
25+
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
26+
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
27+
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
28+
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
29+
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
30+
31+
require 'redis'
32+
33+
class ConsistencyTester
34+
def initialize(redis)
35+
@r = redis
36+
@working_set = 10000
37+
@keyspace = 100000
38+
@writes = 0
39+
@reads = 0
40+
@failed_writes = 0
41+
@failed_reads = 0
42+
@lost_writes = 0
43+
@not_ack_writes = 0
44+
@delay = 0
45+
@cached = {} # We take our view of data stored in the DB.
46+
@prefix = [Process.pid.to_s,Time.now.usec,@r.object_id,""].join("|")
47+
@errtime = {}
48+
end
49+
50+
def genkey
51+
# Write more often to a small subset of keys
52+
ks = rand() > 0.5 ? @keyspace : @working_set
53+
@prefix+"key_"+rand(ks).to_s
54+
end
55+
56+
def check_consistency(key,value)
57+
expected = @cached[key]
58+
return if !expected # We lack info about previous state.
59+
if expected > value
60+
@lost_writes += expected-value
61+
elsif expected < value
62+
@not_ack_writes += value-expected
63+
end
64+
end
65+
66+
def puterr(msg)
67+
if !@errtime[msg] || Time.now.to_i != @errtime[msg]
68+
puts msg
69+
end
70+
@errtime[msg] = Time.now.to_i
71+
end
72+
73+
def test
74+
last_report = Time.now.to_i
75+
while true
76+
# Read
77+
key = genkey
78+
begin
79+
val = @r.get(key)
80+
check_consistency(key,val.to_i)
81+
@reads += 1
82+
rescue => e
83+
puterr "Reading: #{e.to_s}"
84+
@failed_reads += 1
85+
end
86+
87+
# Write
88+
begin
89+
@cached[key] = @r.incr(key).to_i
90+
@writes += 1
91+
rescue => e
92+
puterr "Writing: #{e.to_s}"
93+
@failed_writes += 1
94+
end
95+
96+
# Report
97+
sleep @delay
98+
if Time.now.to_i != last_report
99+
report = "#{@reads} R (#{@failed_reads} err) | " +
100+
"#{@writes} W (#{@failed_writes} err) | "
101+
report += "#{@lost_writes} lost | " if @lost_writes > 0
102+
report += "#{@not_ack_writes} noack | " if @not_ack_writes > 0
103+
last_report = Time.now.to_i
104+
puts report
105+
end
106+
end
107+
end
108+
end
109+
110+
Sentinels = [{:host => "127.0.0.1", :port => 26380},
111+
{:host => "127.0.0.1", :port => 26381}]
112+
r = Redis.new(:url => "sentinel://mymaster", :sentinels => Sentinels, :role => :master)
113+
tester = ConsistencyTester.new(r)
114+
tester.test

examples/sentinel.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
require 'redis'
2+
3+
Sentinels = [{:host => "127.0.0.1", :port => 26379},
4+
{:host => "127.0.0.1", :port => 26380}]
5+
r = Redis.new(:url => "sentinel://mymaster", :sentinels => Sentinels, :role => :master)
6+
7+
# Set keys into a loop.
8+
#
9+
# The example traps errors so that you can actually try to failover while
10+
# running the script to see redis-rb reconfiguring.
11+
(0..1000000).each{|i|
12+
begin
13+
r.set(i,i)
14+
puts i
15+
rescue
16+
puts "ERROR #{i}"
17+
end
18+
sleep(0.01)
19+
}

lib/redis/client.rb

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,25 @@ def connect
8585
establish_connection
8686
call [:auth, password] if password
8787
call [:select, db] if db != 0
88+
if @options[:sentinels]
89+
# Check the instance is really of the role we are looking for.
90+
# We can't assume the command is supported since it was
91+
# introduced recently and this client should work with old stuff.
92+
role = nil
93+
begin
94+
role = call [:role]
95+
rescue
96+
# Assume the test is passed if we can't get a reply from ROLE...
97+
role = [@options[:role].to_s]
98+
end
99+
100+
# Raise an error on role mismatch. TODO: we could do better, wait
101+
# some time and connect again...
102+
if role[0] != @options[:role].to_s
103+
disconnect
104+
raise ConnectionError, "Instance role mismatch, try again."
105+
end
106+
end
88107
end
89108

90109
self
@@ -300,9 +319,60 @@ def logging(commands)
300319
end
301320
end
302321

322+
def set_addr_via_sentinel
323+
responder = nil # The Sentinel that was able to reply
324+
325+
# Try one Sentinel after the other, using the list provided
326+
# by the user.
327+
@options[:sentinels].each{|sentinel|
328+
begin
329+
if !sentinel[:link]
330+
sentinel[:link] = Redis.new(:host => sentinel[:host],
331+
:port => sentinel[:port],
332+
:timeout => 0.300)
333+
end
334+
if @options[:role] == :master
335+
reply = sentinel[:link].client.call(["sentinel","get-master-addr-by-name",@options[:mastername]])
336+
next if !reply
337+
# Got it, set :host and :port
338+
@options[:host] = reply[0]
339+
@options[:port] = reply[1]
340+
responder = sentinel
341+
break
342+
elsif @options[:role] == :slave
343+
reply = sentinel[:link].client.call(["sentinel","slaves",@options[:mastername]])
344+
slaves = []
345+
reply.each{|slave|
346+
slaves << Hash[*slave]
347+
}
348+
random_slave = slaves[rand(slaves.length)]
349+
@options[:host] = random_slave['ip']
350+
@options[:port] = random_slave['port']
351+
responder = sentinel
352+
break
353+
else
354+
raise ArgumentError, "Unknown instance role #{@options[:role]}"
355+
end
356+
rescue
357+
next; # Try the next one on error
358+
end
359+
}
360+
361+
if responder
362+
# If we were able to obtain the address, make sure to put the Sentinel
363+
# that was able to reply as the first in the list.
364+
@options[:sentinels].delete(responder)
365+
@options[:sentinels].unshift(responder)
366+
else
367+
raise ConnectionError, "Unable to fetch #{@options[:role]} via Sentinel."
368+
end
369+
end
370+
303371
def establish_connection
372+
if @options[:sentinels]
373+
set_addr_via_sentinel
374+
end
304375
@connection = @options[:driver].connect(@options.dup)
305-
306376
rescue TimeoutError
307377
raise CannotConnectError, "Timed out connecting to Redis on #{location}"
308378
rescue Errno::ECONNREFUSED
@@ -365,7 +435,7 @@ def _parse_options(options)
365435

366436
if uri.scheme == "unix"
367437
defaults[:path] = uri.path
368-
else
438+
elsif uri.scheme == "redis"
369439
# Require the URL to have at least a host
370440
raise ArgumentError, "invalid url" unless uri.host
371441

@@ -374,6 +444,14 @@ def _parse_options(options)
374444
defaults[:port] = uri.port if uri.port
375445
defaults[:password] = CGI.unescape(uri.password) if uri.password
376446
defaults[:db] = uri.path[1..-1].to_i if uri.path
447+
elsif uri.scheme == "sentinel"
448+
defaults[:scheme] = uri.scheme
449+
defaults[:mastername] = uri.host
450+
defaults[:role] = :master
451+
defaults[:password] = CGI.unescape(uri.password) if uri.password
452+
defaults[:db] = uri.path[1..-1].to_i if uri.path
453+
else
454+
raise ArgumentError, "invalid uri scheme '#{uri.scheme}'"
377455
end
378456
end
379457

@@ -383,10 +461,19 @@ def _parse_options(options)
383461
end
384462

385463
if options[:path]
464+
# Unix socket
386465
options[:scheme] = "unix"
387466
options.delete(:host)
388467
options.delete(:port)
468+
elsif options[:mastername]
469+
# Sentinel
470+
options.delete(:host)
471+
options.delete(:port)
472+
if options[:sentinels].nil?
473+
raise ArgumentError, "list of Sentinels required"
474+
end
389475
else
476+
# TCP socket
390477
options[:host] = options[:host].to_s
391478
options[:port] = options[:port].to_i
392479
end

0 commit comments

Comments
 (0)