redic-sentinel 1.5.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,3 @@
1
+ require "redic-sentinel/version"
2
+ require "redic-sentinel/client"
3
+ require "redic-sentinel/redic"
@@ -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,9 @@
1
+ require "redic"
2
+
3
+ class Redic
4
+ def initialize(url = "redis://127.0.0.1:6379", timeout = 10_000_000, options = {})
5
+ @url = url
6
+ @client = Redic::Client.new(url, timeout, options)
7
+ @queue = []
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ class Redic
2
+ module Sentinel
3
+ VERSION = "1.5.1"
4
+ end
5
+ 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