redic-sentinel 1.5.1
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.
- 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
|