redis-cluster 1.0.1.pre.rc.1 → 1.1.0.pre.rc.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f8427709e6b67eaf1266d6e330dfc37958fbb10dc0ab3845ee97a14319466ce
4
- data.tar.gz: f26b971ccc78221ef3a3a1c6da1207e71d8b594d3c955ee529743abea5fb4c08
3
+ metadata.gz: 2520c212f0cb77a948aef0fe024f39d00cdb4231723e8a308bcf5247c5e1c6ac
4
+ data.tar.gz: 20ce3bd10f0d341040e99bc64e685c885689ecb9d35c72c447f76a9f91897f10
5
5
  SHA512:
6
- metadata.gz: bc342af53829102b0da2108ca999ca74473d62e54adeb3238e898f1e899a4510d5785b07c838b22a282dce5c65e59a8187f34cc3fc6595957dc36289d4595778
7
- data.tar.gz: 95221ec6964ab2aac0c639a4da59a0f501489e97ad5e27eb27be5f61a5c5158d96ae7c425eb9145b9360df06bd198b8510d96e8cbd2ed1226109ee98300bd6c9
6
+ metadata.gz: f64e6681e26e57b3a5d29244788f68b7ec45f847de472b0fecc5a5c5658621be096a5f03f6c6f5c145e249f6a25ddd9afc189dfd021aed9236ecca0e7fabfa6c
7
+ data.tar.gz: 576b6c1c881c10bf58b45aaaca92441e2985a5d8925b862eb854a18b4798046f2910778ae5284d3115184f532d003e73079e69be2bf302f935d2447d4e6fefe8
data/README.md CHANGED
@@ -94,6 +94,9 @@ Option for RedisCluster.
94
94
  - `read_mode`: for read command, RedisClient can try to read from slave if specified. Supported option is `:master`(default), `:slave`, and `:master_slave`.
95
95
  - `silent`: whether or not RedisCluster will raise error.
96
96
  - `logger`: if specified. RedisCluster will log all of RedisCluster errors here.
97
+ - `reset_interval`: reset threshold. A reset can only happen once per reset_interval.
98
+ - `circuit_threshold`: Threshold how many error that client will considered an unhealthy.
99
+ - `circuit_interval`: How long failed count will be remembered.
97
100
 
98
101
  #### Middlewares
99
102
 
data/lib/redis-cluster.rb CHANGED
@@ -4,6 +4,7 @@ require 'redis'
4
4
 
5
5
  require_relative 'redis_cluster/cluster'
6
6
  require_relative 'redis_cluster/client'
7
+ require_relative 'redis_cluster/circuit'
7
8
  require_relative 'redis_cluster/future'
8
9
  require_relative 'redis_cluster/function'
9
10
  require_relative 'redis_cluster/middlewares'
@@ -87,17 +88,20 @@ class RedisCluster
87
88
  while !@pipeline.empty? && try.positive?
88
89
  try -= 1
89
90
  moved = false
91
+ down = false
90
92
  mapped_future = map_pipeline(@pipeline)
91
93
 
92
94
  @pipeline = []
93
95
  mapped_future.each do |url, futures|
94
96
  leftover, error = do_pipelined(url, futures)
95
- moved ||= (error == :moved || error == :down)
97
+ moved ||= error == :moved
98
+ down ||= error == :down
96
99
 
97
100
  @pipeline.concat(leftover)
98
101
  end
99
102
 
100
- cluster.reset if moved
103
+ # force if moved, do not force if down
104
+ cluster.reset(force: moved) if moved || down
101
105
  end
102
106
 
103
107
  @pipeline.first.value unless @pipeline.empty?
@@ -115,36 +119,28 @@ class RedisCluster
115
119
  end
116
120
 
117
121
  def call_immediately(slot, command, transform:, read: false)
118
- try = 3
119
- asking = false
120
- reply = nil
121
122
  mode = read ? :read : :write
122
123
  client = cluster.client_for(mode, slot)
123
124
 
124
- while try.positive?
125
- begin
126
- try -= 1
127
-
128
- client.push([:asking]) if asking
129
- reply = client.call(command)
125
+ # first attempt
126
+ reply = client.call(command)
127
+ err, url = scan_reply(reply)
128
+ return transform.call(reply) unless err
130
129
 
131
- err, url = scan_reply(reply)
132
- return transform.call(reply) unless err
130
+ # make adjustment for cluster change
131
+ cluster.reset(force: true) if err == :moved
132
+ client = cluster[url]
133
133
 
134
- cluster.reset if err == :moved
135
- asking = err == :ask
136
- client = cluster[url]
137
- rescue NodeUnhealthyError, Redis::CannotConnectError => e
138
- if e.is_a?(Redis::CannotConnectError)
139
- asking = false
140
- cluster.reset
141
- end
142
- client = cluster.client_for(mode, slot)
143
- reply = e
144
- end
145
- end
134
+ # second attempt
135
+ client.push([:asking]) if err == :ask
136
+ reply = client.call(command)
137
+ err, = scan_reply(reply)
138
+ raise err if err
146
139
 
147
- raise reply
140
+ transform.call(reply)
141
+ rescue LoadingStateError, CircuitOpenError, Redis::BaseConnectionError => e
142
+ cluster.reset
143
+ raise e
148
144
  end
149
145
 
150
146
  def call_pipeline(slot, command, opts)
@@ -171,6 +167,7 @@ class RedisCluster
171
167
  idx = 0
172
168
  client = cluster[url]
173
169
 
170
+ # map reverse index for pipeline commands.
174
171
  futures.each_with_index do |future, i|
175
172
  if future.asking
176
173
  client.push([:asking])
@@ -198,11 +195,11 @@ class RedisCluster
198
195
  end
199
196
 
200
197
  [leftover, error]
201
- rescue NodeUnhealthyError, Redis::CannotConnectError => e
198
+ rescue LoadingStateError, CircuitOpenError, Redis::BaseConnectionError
202
199
  # reset url and asking when connection refused
203
200
  futures.each{ |f| f.url = nil; f.asking = false }
204
201
 
205
- [futures, e.is_a?(NodeUnhealthyError) ? :loading : :down]
202
+ [futures, :down]
206
203
  end
207
204
 
208
205
  def scan_reply(reply)
@@ -220,6 +217,7 @@ class RedisCluster
220
217
  host, port = url.split(':', 2)
221
218
  Client.new(redis_opts.merge(host: host, port: port)).tap do |c|
222
219
  c.middlewares = middlewares
220
+ c.circuit = Circuit.new(cluster_opts[:circuit_threshold].to_f, cluster_opts[:circuit_interval].to_f)
223
221
  end
224
222
  end
225
223
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RedisCluster
4
+
5
+ # Circuit is circuit breaker for RedisCluster.
6
+ class Circuit
7
+
8
+ attr_reader :fail_count, :ban_until
9
+
10
+ def initialize(threshold, interval)
11
+ @ban_until = Time.now
12
+ @fail_count = 0
13
+ @last_fail_time = Time.now
14
+ @fail_threshold = threshold
15
+ @interval_time = interval
16
+ end
17
+
18
+ # Failed is a method to add failed count and compare it to threshold,
19
+ # Will trip circuit if the count goes through threshold.
20
+ #
21
+ # @return[void]
22
+ def failed
23
+ @fail_count = 0 if @last_fail_time + (@interval_time * 1.5) < Time.now
24
+ @fail_count += 1
25
+ @last_fail_time = Time.now
26
+ open! if @fail_count >= @fail_threshold
27
+ end
28
+
29
+ # Open! is a method to update ban time.
30
+ #
31
+ # @return[void]
32
+ def open!
33
+ @ban_until = Time.now + @interval_time
34
+ end
35
+
36
+ # Open? is a method to check if the circuit breaker status.
37
+ #
38
+ # @return[Boolean] Wheter the circuit is open or not
39
+ def open?
40
+ @ban_until > Time.now
41
+ end
42
+
43
+ end
44
+ end
@@ -6,25 +6,27 @@ require_relative 'version'
6
6
 
7
7
  class RedisCluster
8
8
 
9
- class NodeUnhealthyError < StandardError; end
9
+ # LoadingStateError is an error when try to read redis that in loading state.
10
+ class LoadingStateError < StandardError; end
11
+
12
+ # CircuitOpenError is an error that fired when circuit in client is trip.
13
+ class CircuitOpenError < StandardError; end
10
14
 
11
15
  # Client is a decorator object for Redis::Client. It add queue to support pipelining and another
12
16
  # useful addition
13
17
  class Client
14
18
  attr_reader :client, :queue, :url
15
- attr_accessor :middlewares
19
+ attr_accessor :middlewares, :circuit, :role, :refresh
16
20
 
17
21
  def initialize(opts)
18
22
  @client = Redis::Client.new(opts)
19
23
  @queue = []
20
24
  @url = "#{client.host}:#{client.port}"
21
-
22
- @healthy = true
23
- @ban_from = nil
25
+ @ready = false
24
26
  end
25
27
 
26
28
  def inspect
27
- "#<RedisCluster client v#{RedisCluster::VERSION} for #{url}>"
29
+ "#<RedisCluster client v#{RedisCluster::VERSION} for #{url} (#{role} at #{refresh}) status #{healthy? ? 'healthy' : 'unhealthy'}>"
28
30
  end
29
31
 
30
32
  def connected?
@@ -52,21 +54,23 @@ class RedisCluster
52
54
  end
53
55
  end
54
56
 
57
+ # Healthy? will retrun true if circuit breaker is not open and Redis Client is ready.
58
+ # Redis Client will be ready if it already sent with `readonly` command.
59
+ #
60
+ # return [Boolean] Whether client is healthy or not.
55
61
  def healthy?
56
- return true if @healthy
57
-
58
- # ban for 60 seconds for unhealthy state
59
- if Time.now - @ban_from > 60
60
- @healthy = true
61
- @ban_from = nil
62
- end
62
+ return false if @circuit.open?
63
+ prepare unless @ready
63
64
 
64
- @healthy
65
+ true
66
+ rescue StandardError
67
+ false
65
68
  end
66
69
 
67
70
  private
68
71
 
69
72
  def _commit
73
+ raise CircuitOpenError, "Circuit open in client #{url} until #{@circuit.ban_until}" if @circuit.open?
70
74
  return nil if queue.empty?
71
75
 
72
76
  result = Array.new(queue.size)
@@ -74,26 +78,26 @@ class RedisCluster
74
78
  queue.size.times do |i|
75
79
  result[i] = client.read
76
80
 
77
- unhealthy!(result[i]) if error?(result[i])
81
+ if result[i].is_a?(Redis::CommandError) && result[i].message['LOADING']
82
+ @circuit.open!
83
+ raise LoadingStateError, "Client #{url} is in Loading State"
84
+ end
78
85
  end
79
86
  end
80
87
 
81
88
  result
89
+ rescue LoadingStateError, CircuitOpenError, Redis::BaseConnectionError => e
90
+ @circuit.failed
91
+ @ready = false if @circuit.open?
92
+
93
+ [e] # return this
82
94
  ensure
83
95
  @queue = []
84
96
  end
85
97
 
86
- def unhealthy!(cause)
87
- @healthy = false
88
- @ban_from = Time.now
89
-
90
- error = NodeUnhealthyError.new("Node #{@url} is unhealthy: #{cause}")
91
- error.set_backtrace(cause.backtrace)
92
- raise error
93
- end
94
-
95
- def error?(res)
96
- res.is_a?(Redis::CommandError) && (res.message['LOADING'] || res.message['CLUSTERDOWN'])
98
+ def prepare
99
+ call([:readonly])
100
+ @ready = true
97
101
  end
98
102
  end
99
103
  end
@@ -16,10 +16,8 @@ class RedisCluster
16
16
  @slots = []
17
17
  @clients = {}
18
18
  @replicas = nil
19
- @client_creater = block || proc do |url|
20
- host, port = url.split(':', 2)
21
- Client.new(host: host, port: port)
22
- end
19
+ @client_creater = block
20
+ @last_reset = Time.now - reset_interval
23
21
 
24
22
  @buffer = []
25
23
  init_client(seeds)
@@ -33,8 +31,11 @@ class RedisCluster
33
31
  options[:read_mode] || :master
34
32
  end
35
33
 
36
- def logger
37
- options[:logger]
34
+ # Reset_interval return interval in second which reset can happen. A reset can only happen once per reset_interval.
35
+ #
36
+ # @return [Fixnum] reset interval
37
+ def reset_interval
38
+ options[:reset_interval].to_f
38
39
  end
39
40
 
40
41
  def slot_for(keys)
@@ -67,15 +68,28 @@ class RedisCluster
67
68
  clients.values.sample
68
69
  end
69
70
 
70
- def reset
71
+ # Reset will reload cluster topology. Reset will only be executed once per reset_interval if not forced.
72
+ #
73
+ # @param [Boolean] force: Whether to force reset to happen or not.
74
+ # @return [void]
75
+ def reset(force: false)
76
+ return if !force && @last_reset + reset_interval > Time.now
77
+
71
78
  try = 3
79
+ # binding.pry
80
+ pool = clients.values.select(&:healthy?)
72
81
  begin
73
82
  try -= 1
74
- client = random
83
+ raise 'No healthy seed' if pool.length.zero?
84
+
85
+ i = rand(pool.length)
86
+ client = pool[i]
75
87
  slots_and_clients(client)
76
88
  rescue StandardError => e
89
+ pool.delete_at(i)
77
90
  try.positive? ? retry : (raise e)
78
91
  end
92
+ @last_reset = Time.now
79
93
  end
80
94
 
81
95
  def [](url)
@@ -89,18 +103,20 @@ class RedisCluster
89
103
  private
90
104
 
91
105
  def pick_client(pool, skip: 0)
92
- buff_len = 0
106
+ unhealthy_count = 0
93
107
 
94
108
  (skip...pool.length).each do |i|
95
- next unless pool[i].healthy?
96
-
97
- @buffer[buff_len] = pool[i]
98
- buff_len += 1
109
+ if pool[i].healthy?
110
+ @buffer[i - skip] = pool[i]
111
+ else
112
+ unhealthy_count += 1
113
+ end
99
114
  end
100
115
 
101
- return nil if buff_len.zero?
116
+ buffer_length = pool.length - skip - unhealthy_count
117
+ return nil if buffer_length.zero?
102
118
 
103
- i = rand(buff_len)
119
+ i = rand(buffer_length)
104
120
  @buffer[i]
105
121
  end
106
122
 
@@ -122,11 +138,9 @@ class RedisCluster
122
138
  arr[2..-1].each_with_index do |a, i|
123
139
  cli = self["#{a[0]}:#{a[1]}"]
124
140
  replicas[arr[0]] << cli
125
- begin
126
- cli.call([:readonly]) if i.nonzero?
127
- rescue NodeUnhealthyError => e
128
- logger&.error(e)
129
- end
141
+
142
+ cli.role = i.zero? ? :master : :slave
143
+ cli.refresh = Time.now
130
144
  end
131
145
 
132
146
  (arr[0]..arr[1]).each do |slot|
@@ -139,21 +153,12 @@ class RedisCluster
139
153
  end
140
154
 
141
155
  def init_client(seeds)
142
- try = seeds.count
143
- err = nil
144
-
145
- while try.positive?
146
- try -= 1
147
- begin
148
- client = create_client(seeds[try])
149
- slots_and_clients(client)
150
- return
151
- rescue StandardError => e
152
- err = e
153
- end
156
+ # register seeds into clients
157
+ seeds.each do |s|
158
+ self[s]
154
159
  end
155
160
 
156
- raise err
161
+ reset(force: true)
157
162
  end
158
163
 
159
164
  def create_client(url)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class RedisCluster
4
- VERSION = '1.0.1-rc.1'
4
+ VERSION = '1.1.0-rc.1'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: redis-cluster
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1.pre.rc.1
4
+ version: 1.1.0.pre.rc.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bukalapak
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-10-25 00:00:00.000000000 Z
11
+ date: 2019-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis
@@ -33,6 +33,7 @@ extra_rdoc_files: []
33
33
  files:
34
34
  - README.md
35
35
  - lib/redis-cluster.rb
36
+ - lib/redis_cluster/circuit.rb
36
37
  - lib/redis_cluster/client.rb
37
38
  - lib/redis_cluster/cluster.rb
38
39
  - lib/redis_cluster/function.rb
@@ -66,7 +67,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
66
67
  version: 1.3.1
67
68
  requirements: []
68
69
  rubyforge_project:
69
- rubygems_version: 2.7.7
70
+ rubygems_version: 2.7.9
70
71
  signing_key:
71
72
  specification_version: 4
72
73
  summary: Redis cluster client. Support pipelining.