redic-sentinel 1.5.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/CHANGELOG.md +74 -0
- data/CONTRIBUTING.md +12 -0
- data/Gemfile +6 -0
- data/MIT-LICENSE +22 -0
- data/README.md +104 -0
- data/Rakefile +27 -0
- data/example/redis-master.conf +540 -0
- data/example/redis-sentinel1.conf +5 -0
- data/example/redis-sentinel2.conf +5 -0
- data/example/redis-sentinel3.conf +5 -0
- data/example/redis-slave.conf +541 -0
- data/example/test.rb +18 -0
- data/example/test_wait_for_failover.rb +19 -0
- data/example/test_wait_for_failover_write.rb +21 -0
- data/lib/redic-sentinel.rb +3 -0
- data/lib/redic-sentinel/client.rb +188 -0
- data/lib/redic-sentinel/redic.rb +9 -0
- data/lib/redic-sentinel/version.rb +5 -0
- data/redic-sentinel.gemspec +27 -0
- data/spec/redic-sentinel/client_spec.rb +196 -0
- data/spec/spec_helper.rb +13 -0
- metadata +169 -0
data/example/test.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'redis'
|
2
|
+
require 'redis-sentinel'
|
3
|
+
|
4
|
+
redis = Redis.new(:master_name => "example-test",
|
5
|
+
:sentinels => [
|
6
|
+
{:host => "localhost", :port => 26379},
|
7
|
+
{:host => "localhost", :port => 26380}
|
8
|
+
])
|
9
|
+
redis.set "foo", "bar"
|
10
|
+
|
11
|
+
while true
|
12
|
+
begin
|
13
|
+
puts redis.get "foo"
|
14
|
+
rescue => e
|
15
|
+
puts "failed?", e
|
16
|
+
end
|
17
|
+
sleep 1
|
18
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'redis'
|
2
|
+
require 'redis-sentinel'
|
3
|
+
|
4
|
+
redis = Redis.new(:master_name => "example-test",
|
5
|
+
:sentinels => [
|
6
|
+
{:host => "localhost", :port => 26379},
|
7
|
+
{:host => "localhost", :port => 26380}
|
8
|
+
],
|
9
|
+
:failover_reconnect_timeout => 30)
|
10
|
+
redis.set "foo", "bar"
|
11
|
+
|
12
|
+
while true
|
13
|
+
begin
|
14
|
+
puts redis.get "foo"
|
15
|
+
rescue => e
|
16
|
+
puts "failover took too long to recover", e
|
17
|
+
end
|
18
|
+
sleep 1
|
19
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'redis'
|
2
|
+
require 'redis-sentinel'
|
3
|
+
|
4
|
+
redis = Redis.new(:master_name => "example-test",
|
5
|
+
:sentinels => [
|
6
|
+
{:host => "localhost", :port => 26379},
|
7
|
+
{:host => "localhost", :port => 26380}
|
8
|
+
],
|
9
|
+
:failover_reconnect_timeout => 30,
|
10
|
+
:failover_reconnect_wait => 0.0001)
|
11
|
+
|
12
|
+
redis.set "foo", 1
|
13
|
+
|
14
|
+
while true
|
15
|
+
begin
|
16
|
+
puts redis.incr "foo"
|
17
|
+
rescue Redis::CannotConnectError => e
|
18
|
+
puts "failover took too long to recover", e
|
19
|
+
end
|
20
|
+
sleep 1
|
21
|
+
end
|
@@ -0,0 +1,188 @@
|
|
1
|
+
require "redic"
|
2
|
+
require "redis"
|
3
|
+
|
4
|
+
class Redic::Client
|
5
|
+
DEFAULT_FAILOVER_RECONNECT_WAIT_SECONDS = 0.1
|
6
|
+
|
7
|
+
class_eval do
|
8
|
+
attr_reader :current_sentinel
|
9
|
+
attr_reader :uri
|
10
|
+
|
11
|
+
def initialize_with_sentinel(url, timeout, options={})
|
12
|
+
options = options.dup # Don't touch my options
|
13
|
+
@options = options
|
14
|
+
@master_name = fetch_option(options, :master_name)
|
15
|
+
@master_password = fetch_option(options, :master_password)
|
16
|
+
@sentinels_options = _parse_sentinel_options(fetch_option(options, :sentinels))
|
17
|
+
@failover_reconnect_timeout = fetch_option(options, :failover_reconnect_timeout)
|
18
|
+
@failover_reconnect_wait = fetch_option(options, :failover_reconnect_wait) ||
|
19
|
+
DEFAULT_FAILOVER_RECONNECT_WAIT_SECONDS
|
20
|
+
|
21
|
+
Thread.new { watch_sentinel } if sentinel? && !fetch_option(options, :async)
|
22
|
+
|
23
|
+
initialize_without_sentinel(url, timeout)
|
24
|
+
end
|
25
|
+
|
26
|
+
alias initialize_without_sentinel initialize
|
27
|
+
alias initialize initialize_with_sentinel
|
28
|
+
|
29
|
+
def establish_connection_with_sentinel
|
30
|
+
if sentinel?
|
31
|
+
auto_retry_with_timeout do
|
32
|
+
discover_master
|
33
|
+
establish_connection_without_sentinel
|
34
|
+
end
|
35
|
+
else
|
36
|
+
establish_connection_without_sentinel
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
alias establish_connection_without_sentinel establish_connection
|
41
|
+
alias establish_connection establish_connection_with_sentinel
|
42
|
+
|
43
|
+
def sentinel?
|
44
|
+
!!(@master_name && @sentinels_options)
|
45
|
+
end
|
46
|
+
|
47
|
+
def auto_retry_with_timeout(&block)
|
48
|
+
deadline = @failover_reconnect_timeout.to_i + Time.now.to_f
|
49
|
+
begin
|
50
|
+
block.call
|
51
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTDOWN, Errno::EHOSTUNREACH => e
|
52
|
+
raise if Time.now.to_f > deadline
|
53
|
+
sleep @failover_reconnect_wait
|
54
|
+
retry
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def new_sentinel(sentinel_options)
|
59
|
+
Redis.new(sentinel_options)
|
60
|
+
end
|
61
|
+
|
62
|
+
def try_next_sentinel
|
63
|
+
sentinel_options = @sentinels_options.shift
|
64
|
+
@sentinels_options.push sentinel_options
|
65
|
+
|
66
|
+
@logger.debug "Trying next sentinel: #{sentinel_options[:host]}:#{sentinel_options[:port]}" if @logger && @logger.debug?
|
67
|
+
@current_sentinel = new_sentinel(sentinel_options)
|
68
|
+
end
|
69
|
+
|
70
|
+
def refresh_sentinels_list
|
71
|
+
current_sentinel.sentinel("sentinels", @master_name).each do |response|
|
72
|
+
@sentinels_options << {:host => response[3], :port => response[5]}
|
73
|
+
end
|
74
|
+
@sentinels_options.uniq! {|h| h.values_at(:host, :port) }
|
75
|
+
end
|
76
|
+
|
77
|
+
def switch_master(host, port)
|
78
|
+
@uri = URI.parse("redis://#{host}:#{port}/")
|
79
|
+
end
|
80
|
+
|
81
|
+
def discover_master
|
82
|
+
attempts = 0
|
83
|
+
while true
|
84
|
+
attempts += 1
|
85
|
+
try_next_sentinel
|
86
|
+
|
87
|
+
begin
|
88
|
+
master_host, master_port = current_sentinel.sentinel("get-master-addr-by-name", @master_name)
|
89
|
+
if master_host && master_port
|
90
|
+
# An ip:port pair
|
91
|
+
switch_master(master_host, master_port)
|
92
|
+
refresh_sentinels_list
|
93
|
+
break
|
94
|
+
end
|
95
|
+
rescue Redis::CommandError => e
|
96
|
+
raise e unless e.message.include?("IDONTKNOW")
|
97
|
+
rescue Redis::CannotConnectError, Redis::ConnectionError, Errno::EHOSTDOWN, Errno::EHOSTUNREACH => e
|
98
|
+
# failed to connect to current sentinel server
|
99
|
+
end
|
100
|
+
|
101
|
+
raise "Cannot connect to master (too many attempts)" if attempts > @sentinels_options.count
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def call_with_readonly_protection(*args, &block)
|
106
|
+
readonly_protection_with_timeout(:call_without_readonly_protection, *args, &block)
|
107
|
+
end
|
108
|
+
|
109
|
+
alias call_without_readonly_protection call
|
110
|
+
alias call call_with_readonly_protection
|
111
|
+
|
112
|
+
def watch_sentinel
|
113
|
+
while true
|
114
|
+
puts "Acquire new sentinel"
|
115
|
+
sentinel = new_sentinel(@sentinels_options[0])
|
116
|
+
|
117
|
+
begin
|
118
|
+
puts "Subscribe sentinel"
|
119
|
+
sentinel.psubscribe("*") do |on|
|
120
|
+
on.pmessage do |pattern, channel, message|
|
121
|
+
puts "New message"
|
122
|
+
puts "#{pattern} #{channel} #{message}"
|
123
|
+
next if channel != "+switch-master"
|
124
|
+
|
125
|
+
master_name, old_host, old_port, new_host, new_port = message.split(" ")
|
126
|
+
|
127
|
+
next if master_name != @master_name
|
128
|
+
|
129
|
+
switch_master(new_host, new_port)
|
130
|
+
|
131
|
+
@logger.debug "Failover: #{old_host}:#{old_port} => #{new_host}:#{new_port}" if @logger && @logger.debug?
|
132
|
+
|
133
|
+
@connection = nil
|
134
|
+
end
|
135
|
+
end
|
136
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTDOWN, Errno::EHOSTUNREACH
|
137
|
+
puts "Cannot connect to sentinel"
|
138
|
+
try_next_sentinel
|
139
|
+
sleep 1
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
private
|
145
|
+
def reconnect
|
146
|
+
@connection = nil
|
147
|
+
connect {}
|
148
|
+
end
|
149
|
+
|
150
|
+
def readonly_protection_with_timeout(method, *args, &block)
|
151
|
+
deadline = @failover_reconnect_timeout.to_i + Time.now.to_f
|
152
|
+
send(method, *args, &block)
|
153
|
+
rescue StandardError => e
|
154
|
+
if e.message.include? "READONLY You can't write against a read only slave."
|
155
|
+
reconnect
|
156
|
+
raise if Time.now.to_f > deadline
|
157
|
+
sleep @failover_reconnect_wait
|
158
|
+
retry
|
159
|
+
else
|
160
|
+
raise
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def fetch_option(options, key)
|
165
|
+
options.delete(key) || options.delete(key.to_s)
|
166
|
+
end
|
167
|
+
|
168
|
+
def _parse_sentinel_options(options)
|
169
|
+
return if options.nil?
|
170
|
+
|
171
|
+
sentinel_options = []
|
172
|
+
options.each do |opts|
|
173
|
+
opts = opts[:url] if opts.is_a?(Hash) && opts.key?(:url)
|
174
|
+
case opts
|
175
|
+
when Hash
|
176
|
+
sentinel_options << opts
|
177
|
+
else
|
178
|
+
uri = URI.parse(opts)
|
179
|
+
sentinel_options << {
|
180
|
+
:host => uri.host,
|
181
|
+
:port => uri.port
|
182
|
+
}
|
183
|
+
end
|
184
|
+
end
|
185
|
+
sentinel_options
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'redic-sentinel/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "redic-sentinel"
|
8
|
+
gem.version = Redic::Sentinel::VERSION
|
9
|
+
gem.authors = ["Richard Huang", "Phuong Gia Su"]
|
10
|
+
gem.email = ["flyerhzm@gmail.com", "phuongnd08@gmail.com"]
|
11
|
+
gem.description = %q{automatic master/slave failover solution for redic by using built-in redis sentinel}
|
12
|
+
gem.summary = %q{automatic master/slave failover solution for redic by using built-in redis sentinel}
|
13
|
+
gem.homepage = "https://github.com/phuongnd08/redic-sentinel"
|
14
|
+
|
15
|
+
gem.files = `git ls-files`.split($/)
|
16
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
17
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
18
|
+
gem.require_paths = ["lib"]
|
19
|
+
|
20
|
+
gem.add_dependency "redic"
|
21
|
+
gem.add_dependency "redis"
|
22
|
+
gem.add_development_dependency "rake"
|
23
|
+
gem.add_development_dependency "rspec"
|
24
|
+
gem.add_development_dependency "eventmachine"
|
25
|
+
gem.add_development_dependency "em-synchrony"
|
26
|
+
gem.add_development_dependency "hiredis"
|
27
|
+
end
|
@@ -0,0 +1,196 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe Redic::Client do
|
4
|
+
let(:client) { double("Client", :reconnect => true) }
|
5
|
+
let(:current_sentinel) { double("Redis", :client => client) }
|
6
|
+
|
7
|
+
let(:sentinels) do
|
8
|
+
[
|
9
|
+
{ :host => "localhost", :port => 26379 },
|
10
|
+
'sentinel://localhost:26380',
|
11
|
+
{ :url => 'sentinel://localhost:26381' },
|
12
|
+
]
|
13
|
+
end
|
14
|
+
|
15
|
+
let(:slaves_reply) do
|
16
|
+
[
|
17
|
+
["ip", "slave-0", "port", "6379"],
|
18
|
+
["ip", "slave-1", "port", "6380"],
|
19
|
+
["ip", "slave-2", "port", "6381"]
|
20
|
+
]
|
21
|
+
end
|
22
|
+
|
23
|
+
subject { Redic::Client.new("redis://test.host:6379/", 10_000_000, :master_name => "master", :master_password => "foobar",
|
24
|
+
:sentinels => sentinels) }
|
25
|
+
|
26
|
+
context "new instances" do
|
27
|
+
it "should parse sentinel options" do
|
28
|
+
expect(subject.instance_variable_get(:@sentinels_options)).to match_array [
|
29
|
+
{:host=>"localhost", :port=>26379},
|
30
|
+
{:host=>"localhost", :port=>26380},
|
31
|
+
{:host=>"localhost", :port=>26381}
|
32
|
+
]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
context "#sentinel?" do
|
37
|
+
it "should be true if passing sentiels and master_name options" do
|
38
|
+
expect(subject).to be_sentinel
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should not be true if not passing sentinels and master_name options" do
|
42
|
+
expect(Redic::Client.new("redis://test.host:6379/", 10_000_000)).not_to be_sentinel
|
43
|
+
end
|
44
|
+
|
45
|
+
it "should not be true if passing sentinels option but not master_name option" do
|
46
|
+
client = Redic::Client.new(
|
47
|
+
"redis://test.host:6379/", 10_000_000,
|
48
|
+
:sentinels => [
|
49
|
+
{:host => "localhost", :port => 26379},
|
50
|
+
{:host => "localhost", :port => 26380}
|
51
|
+
])
|
52
|
+
expect(client).not_to be_sentinel
|
53
|
+
end
|
54
|
+
|
55
|
+
it "should not be true if passing master_name option but not sentinels option" do
|
56
|
+
client = Redic::Client.new("redis://test.host:6379/", 10_000_000, :master_name => "master")
|
57
|
+
expect(client).not_to be_sentinel
|
58
|
+
end
|
59
|
+
|
60
|
+
it "should be true if passing master_name, and sentinels as uri" do
|
61
|
+
client = Redic::Client.new("redis://test.host:6379/", 10_000_000, :master_name => "master",
|
62
|
+
:sentinels => %w(sentinel://localhost:26379 sentinel://localhost:26380))
|
63
|
+
expect(client).to be_sentinel
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
context "#try_next_sentinel" do
|
68
|
+
before { allow(Redis).to receive(:new).and_return(current_sentinel) }
|
69
|
+
|
70
|
+
it "returns next sentinel server" do
|
71
|
+
expect(subject.try_next_sentinel).to eq current_sentinel
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
context "#refresh_sentinels_list" do
|
76
|
+
it "gets all sentinels list" do
|
77
|
+
allow(subject).to receive(:current_sentinel).and_return(current_sentinel)
|
78
|
+
expect(current_sentinel).to receive(:sentinel).with("sentinels", "master").and_return([
|
79
|
+
["name", "localhost:26381", "ip", "localhost", "port", 26380],
|
80
|
+
["name", "localhost:26381", "ip", "localhost", "port", 26381],
|
81
|
+
["name", "localhost:26381", "ip", "localhost", "port", 26382],
|
82
|
+
])
|
83
|
+
subject.refresh_sentinels_list
|
84
|
+
expect(subject.instance_variable_get(:@sentinels_options)).to eq [
|
85
|
+
{:host => "localhost", :port => 26379},
|
86
|
+
{:host => "localhost", :port => 26380},
|
87
|
+
{:host => "localhost", :port => 26381},
|
88
|
+
{:host => "localhost", :port => 26382},
|
89
|
+
]
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
context "#discover_master" do
|
94
|
+
before do
|
95
|
+
allow(subject).to receive(:try_next_sentinel)
|
96
|
+
allow(subject).to receive(:refresh_sentinels_list)
|
97
|
+
allow(subject).to receive(:current_sentinel).and_return(current_sentinel)
|
98
|
+
end
|
99
|
+
|
100
|
+
it "updates master config options" do
|
101
|
+
expect(current_sentinel).to receive(:sentinel).with("get-master-addr-by-name", "master").and_return(["master", 8888])
|
102
|
+
subject.discover_master
|
103
|
+
expect(subject.uri.to_s).to eq "redis://master:8888/"
|
104
|
+
end
|
105
|
+
|
106
|
+
it "selects next sentinel if failed to connect to current_sentinel" do
|
107
|
+
expect(current_sentinel).to receive(:sentinel).with("get-master-addr-by-name", "master").and_raise(Redis::ConnectionError)
|
108
|
+
expect(current_sentinel).to receive(:sentinel).with("get-master-addr-by-name", "master").and_return(["master", 8888])
|
109
|
+
subject.discover_master
|
110
|
+
expect(subject.uri.to_s).to eq "redis://master:8888/"
|
111
|
+
end
|
112
|
+
|
113
|
+
it "selects next sentinel if sentinel doesn't know" do
|
114
|
+
expect(current_sentinel).to receive(:sentinel).with("get-master-addr-by-name", "master").and_raise(Redis::CommandError.new("IDONTKNOW: No idea"))
|
115
|
+
expect(current_sentinel).to receive(:sentinel).with("get-master-addr-by-name", "master").and_return(["master", 8888])
|
116
|
+
subject.discover_master
|
117
|
+
expect(subject.uri.to_s).to eq "redis://master:8888/"
|
118
|
+
end
|
119
|
+
|
120
|
+
it "raises error if try_next_sentinel raises command error" do
|
121
|
+
expect(current_sentinel).to receive(:sentinel).with("get-master-addr-by-name", "master").and_raise(Redis::CommandError)
|
122
|
+
expect { subject.discover_master }.to raise_error(Redis::CommandError)
|
123
|
+
end
|
124
|
+
|
125
|
+
it "raises error if try_next_sentinel raises connection error" do
|
126
|
+
expect(subject).to receive(:try_next_sentinel).and_raise(Redis::CannotConnectError)
|
127
|
+
expect { subject.discover_master }.to raise_error(Redis::CannotConnectError)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
context "#auto_retry_with_timeout" do
|
132
|
+
context "no failover reconnect timeout set" do
|
133
|
+
subject { Redic::Client.new("redis://test.host:6379/", 10_000_000) }
|
134
|
+
|
135
|
+
it "does not sleep" do
|
136
|
+
expect(subject).not_to receive(:sleep)
|
137
|
+
expect {
|
138
|
+
subject.auto_retry_with_timeout { raise Errno::ECONNREFUSED }
|
139
|
+
}.to raise_error(Errno::ECONNREFUSED)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
context "the failover reconnect timeout is set" do
|
144
|
+
subject { Redic::Client.new("redis://test.host:6379/", 10_000_000, :failover_reconnect_timeout => 3) }
|
145
|
+
|
146
|
+
before(:each) do
|
147
|
+
allow(subject).to receive(:sleep)
|
148
|
+
end
|
149
|
+
|
150
|
+
it "only raises after the failover_reconnect_timeout" do
|
151
|
+
called_counter = 0
|
152
|
+
allow(Time).to receive(:now).and_return(100, 101, 102, 103, 104, 105)
|
153
|
+
|
154
|
+
begin
|
155
|
+
subject.auto_retry_with_timeout do
|
156
|
+
called_counter += 1
|
157
|
+
raise Errno::ECONNREFUSED
|
158
|
+
end
|
159
|
+
rescue Errno::ECONNREFUSED
|
160
|
+
end
|
161
|
+
|
162
|
+
expect(called_counter).to eq(4)
|
163
|
+
end
|
164
|
+
|
165
|
+
it "sleeps the default wait time" do
|
166
|
+
allow(Time).to receive(:now).and_return(100, 101, 105)
|
167
|
+
expect(subject).to receive(:sleep).with(0.1)
|
168
|
+
begin
|
169
|
+
subject.auto_retry_with_timeout { raise Errno::ECONNREFUSED }
|
170
|
+
rescue Errno::ECONNREFUSED
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
it "does not catch other errors" do
|
175
|
+
expect(subject).not_to receive(:sleep)
|
176
|
+
expect do
|
177
|
+
subject.auto_retry_with_timeout { raise "Some other error" }
|
178
|
+
end.to raise_error("Some other error")
|
179
|
+
end
|
180
|
+
|
181
|
+
context "configured wait time" do
|
182
|
+
subject { Redic::Client.new("redis://test.host:6379/", 10_000_000, :failover_reconnect_timeout => 3,
|
183
|
+
:failover_reconnect_wait => 0.01) }
|
184
|
+
|
185
|
+
it "uses the configured wait time" do
|
186
|
+
allow(Time).to receive(:now).and_return(100, 101, 105)
|
187
|
+
expect(subject).to receive(:sleep).with(0.01)
|
188
|
+
begin
|
189
|
+
subject.auto_retry_with_timeout { raise Errno::ECONNREFUSED }
|
190
|
+
rescue Errno::ECONNREFUSED
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|