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.
@@ -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