async-redis 0.9.0 → 0.10.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97fe8ed81d0096bd994cbbd3511fc3acb7c0c4629cfafdac47e5c237137083f1
4
- data.tar.gz: c8a5b6a33efb6de45dc7cc2154b694fbd9337a14c485f85f1693bfeabbcc0f79
3
+ metadata.gz: 31704f6caaeffba85223e9917ac746128c05862b53738ab42d03f0c91bd808c3
4
+ data.tar.gz: 72afd9739ce773d441870c238e9f2600a6ac3e3d46b13bb66c3a7f3febf3f4d6
5
5
  SHA512:
6
- metadata.gz: ad448e4900402ef3e974a247c27a6a8fa5bf65d47fda67b46260830ecfb76a185c86a03ff0c38cd18ae1d628a7ce332d6952e26704d357f3858458fe593fb49d
7
- data.tar.gz: 83535dc337e792808e6e3033864e393cb075a5d709bd5a8c507577d8b792f0600388f98cd28997c891102c36dc79062c6327004359f51c4677b50bab42eced8a
6
+ metadata.gz: 5b12190eab39bba378d6ca1793abd3e56ab513a13af4bedfbb332d3d7e869d5b61c9d3958eaa96f22987e341e50b6acdc018c84c1d88effea3fee72fd132a7fe
7
+ data.tar.gz: '086307e4ec9c27d407cce2e61e99c6b4712e1d1fb3b8bca660c7e1a4270a9aa9061c36dd5a801e3edfa42eb52a6f41a41c858830ccc7e673e38917d0a4f9e700'
checksums.yaml.gz.sig CHANGED
Binary file
@@ -26,6 +26,65 @@ module Async
26
26
  class Client
27
27
  include ::Protocol::Redis::Methods
28
28
 
29
+ module Methods
30
+ def subscribe(*channels)
31
+ context = Context::Subscribe.new(@pool, channels)
32
+
33
+ return context unless block_given?
34
+
35
+ begin
36
+ yield context
37
+ ensure
38
+ context.close
39
+ end
40
+ end
41
+
42
+ def transaction(&block)
43
+ context = Context::Transaction.new(@pool)
44
+
45
+ return context unless block_given?
46
+
47
+ begin
48
+ yield context
49
+ ensure
50
+ context.close
51
+ end
52
+ end
53
+
54
+ alias multi transaction
55
+
56
+ def pipeline(&block)
57
+ context = Context::Pipeline.new(@pool)
58
+
59
+ return context unless block_given?
60
+
61
+ begin
62
+ yield context
63
+ ensure
64
+ context.close
65
+ end
66
+ end
67
+
68
+ # Deprecated.
69
+ alias nested pipeline
70
+
71
+ def call(*arguments)
72
+ @pool.acquire do |connection|
73
+ connection.write_request(arguments)
74
+
75
+ connection.flush
76
+
77
+ return connection.read_response
78
+ end
79
+ end
80
+
81
+ def close
82
+ @pool.close
83
+ end
84
+ end
85
+
86
+ include Methods
87
+
29
88
  def initialize(endpoint = Endpoint.local, protocol: endpoint.protocol, **options)
30
89
  @endpoint = endpoint
31
90
  @protocol = protocol
@@ -38,8 +97,8 @@ module Async
38
97
 
39
98
  # @return [client] if no block provided.
40
99
  # @yield [client, task] yield the client in an async task.
41
- def self.open(*arguments, &block)
42
- client = self.new(*arguments)
100
+ def self.open(*arguments, **options, &block)
101
+ client = self.new(*arguments, **options)
43
102
 
44
103
  return client unless block_given?
45
104
 
@@ -52,61 +111,6 @@ module Async
52
111
  end.wait
53
112
  end
54
113
 
55
- def close
56
- @pool.close
57
- end
58
-
59
- def subscribe(*channels)
60
- context = Context::Subscribe.new(@pool, channels)
61
-
62
- return context unless block_given?
63
-
64
- begin
65
- yield context
66
- ensure
67
- context.close
68
- end
69
- end
70
-
71
- def transaction(&block)
72
- context = Context::Transaction.new(@pool)
73
-
74
- return context unless block_given?
75
-
76
- begin
77
- yield context
78
- ensure
79
- context.close
80
- end
81
- end
82
-
83
- alias multi transaction
84
-
85
- def pipeline(&block)
86
- context = Context::Pipeline.new(@pool)
87
-
88
- return context unless block_given?
89
-
90
- begin
91
- yield context
92
- ensure
93
- context.close
94
- end
95
- end
96
-
97
- # Deprecated.
98
- alias nested pipeline
99
-
100
- def call(*arguments)
101
- @pool.acquire do |connection|
102
- connection.write_request(arguments)
103
-
104
- connection.flush
105
-
106
- return connection.read_response
107
- end
108
- end
109
-
110
114
  protected
111
115
 
112
116
  def connect(**options)
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2020, by David Ortiz.
5
+ # Copyright, 2023-2024, by Samuel Williams.
6
+
7
+ require_relative 'client'
8
+ require 'io/stream'
9
+
10
+ module Async
11
+ module Redis
12
+ class ClusterClient
13
+ class ReloadError < StandardError
14
+ end
15
+
16
+ Node = Struct.new(:id, :endpoint, :role, :health, :client)
17
+
18
+ class RangeMap
19
+ def initialize
20
+ @ranges = []
21
+ end
22
+
23
+ def add(range, value)
24
+ @ranges << [range, value]
25
+
26
+ return value
27
+ end
28
+
29
+ def find(key)
30
+ @ranges.each do |range, value|
31
+ return value if range.include?(key)
32
+ end
33
+
34
+ if block_given?
35
+ return yield
36
+ end
37
+
38
+ return nil
39
+ end
40
+
41
+ def each
42
+ @ranges.each do |range, value|
43
+ yield value
44
+ end
45
+ end
46
+
47
+ def clear
48
+ @ranges.clear
49
+ end
50
+ end
51
+
52
+ # Create a new instance of the cluster client.
53
+ #
54
+ # @property endpoints [Array(Endpoint)] The list of cluster endpoints.
55
+ def initialize(endpoints, **options)
56
+ @endpoints = endpoints
57
+ @shards = nil
58
+ end
59
+
60
+ def clients_for(*keys, role: :master, attempts: 3)
61
+ slots = slots_for(keys)
62
+
63
+ slots.each do |slot, keys|
64
+ yield client_for(slot, role), keys
65
+ end
66
+ rescue ServerError => error
67
+ Console.warn(self, error)
68
+
69
+ if error.message =~ /MOVED|ASK/
70
+ reload_cluster!
71
+
72
+ attempts -= 1
73
+
74
+ retry if attempts > 0
75
+
76
+ raise
77
+ else
78
+ raise
79
+ end
80
+ end
81
+
82
+ def client_for(slot, role = :master)
83
+ unless @shards
84
+ reload_cluster!
85
+ end
86
+
87
+ nodes = @shards.find(slot)
88
+
89
+ nodes = nodes.select{|node| node.role == role}
90
+
91
+ if node = nodes.sample
92
+ return (node.client ||= Client.new(node.endpoint))
93
+ end
94
+ end
95
+
96
+ protected
97
+
98
+ def reload_cluster!(endpoints = @endpoints)
99
+ @endpoints.each do |endpoint|
100
+ client = Client.new(endpoint)
101
+
102
+ shards = RangeMap.new
103
+ endpoints = []
104
+
105
+ client.call('CLUSTER', 'SHARDS').each do |shard|
106
+ shard = shard.each_slice(2).to_h
107
+
108
+ slots = shard['slots']
109
+ range = Range.new(*slots, exclude_end: false)
110
+
111
+ nodes = shard['nodes'].map do |node|
112
+ node = node.each_slice(2).to_h
113
+ endpoint = Endpoint.remote(node['ip'], node['port'])
114
+
115
+ # Collect all endpoints:
116
+ endpoints << endpoint
117
+
118
+ Node.new(node['id'], endpoint, node['role'].to_sym, node['health'].to_sym)
119
+ end
120
+
121
+ shards.add(range, nodes)
122
+ end
123
+
124
+ @shards = shards
125
+ # @endpoints = @endpoints | endpoints
126
+
127
+ return true
128
+ rescue Errno::ECONNREFUSED
129
+ next
130
+ end
131
+
132
+ raise ReloadError, "Failed to reload cluster configuration."
133
+ end
134
+
135
+ XMODEM_CRC16_LOOKUP = [
136
+ 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50a5, 0x60c6, 0x70e7,
137
+ 0x8108, 0x9129, 0xa14a, 0xb16b, 0xc18c, 0xd1ad, 0xe1ce, 0xf1ef,
138
+ 0x1231, 0x0210, 0x3273, 0x2252, 0x52b5, 0x4294, 0x72f7, 0x62d6,
139
+ 0x9339, 0x8318, 0xb37b, 0xa35a, 0xd3bd, 0xc39c, 0xf3ff, 0xe3de,
140
+ 0x2462, 0x3443, 0x0420, 0x1401, 0x64e6, 0x74c7, 0x44a4, 0x5485,
141
+ 0xa56a, 0xb54b, 0x8528, 0x9509, 0xe5ee, 0xf5cf, 0xc5ac, 0xd58d,
142
+ 0x3653, 0x2672, 0x1611, 0x0630, 0x76d7, 0x66f6, 0x5695, 0x46b4,
143
+ 0xb75b, 0xa77a, 0x9719, 0x8738, 0xf7df, 0xe7fe, 0xd79d, 0xc7bc,
144
+ 0x48c4, 0x58e5, 0x6886, 0x78a7, 0x0840, 0x1861, 0x2802, 0x3823,
145
+ 0xc9cc, 0xd9ed, 0xe98e, 0xf9af, 0x8948, 0x9969, 0xa90a, 0xb92b,
146
+ 0x5af5, 0x4ad4, 0x7ab7, 0x6a96, 0x1a71, 0x0a50, 0x3a33, 0x2a12,
147
+ 0xdbfd, 0xcbdc, 0xfbbf, 0xeb9e, 0x9b79, 0x8b58, 0xbb3b, 0xab1a,
148
+ 0x6ca6, 0x7c87, 0x4ce4, 0x5cc5, 0x2c22, 0x3c03, 0x0c60, 0x1c41,
149
+ 0xedae, 0xfd8f, 0xcdec, 0xddcd, 0xad2a, 0xbd0b, 0x8d68, 0x9d49,
150
+ 0x7e97, 0x6eb6, 0x5ed5, 0x4ef4, 0x3e13, 0x2e32, 0x1e51, 0x0e70,
151
+ 0xff9f, 0xefbe, 0xdfdd, 0xcffc, 0xbf1b, 0xaf3a, 0x9f59, 0x8f78,
152
+ 0x9188, 0x81a9, 0xb1ca, 0xa1eb, 0xd10c, 0xc12d, 0xf14e, 0xe16f,
153
+ 0x1080, 0x00a1, 0x30c2, 0x20e3, 0x5004, 0x4025, 0x7046, 0x6067,
154
+ 0x83b9, 0x9398, 0xa3fb, 0xb3da, 0xc33d, 0xd31c, 0xe37f, 0xf35e,
155
+ 0x02b1, 0x1290, 0x22f3, 0x32d2, 0x4235, 0x5214, 0x6277, 0x7256,
156
+ 0xb5ea, 0xa5cb, 0x95a8, 0x8589, 0xf56e, 0xe54f, 0xd52c, 0xc50d,
157
+ 0x34e2, 0x24c3, 0x14a0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405,
158
+ 0xa7db, 0xb7fa, 0x8799, 0x97b8, 0xe75f, 0xf77e, 0xc71d, 0xd73c,
159
+ 0x26d3, 0x36f2, 0x0691, 0x16b0, 0x6657, 0x7676, 0x4615, 0x5634,
160
+ 0xd94c, 0xc96d, 0xf90e, 0xe92f, 0x99c8, 0x89e9, 0xb98a, 0xa9ab,
161
+ 0x5844, 0x4865, 0x7806, 0x6827, 0x18c0, 0x08e1, 0x3882, 0x28a3,
162
+ 0xcb7d, 0xdb5c, 0xeb3f, 0xfb1e, 0x8bf9, 0x9bd8, 0xabbb, 0xbb9a,
163
+ 0x4a75, 0x5a54, 0x6a37, 0x7a16, 0x0af1, 0x1ad0, 0x2ab3, 0x3a92,
164
+ 0xfd2e, 0xed0f, 0xdd6c, 0xcd4d, 0xbdaa, 0xad8b, 0x9de8, 0x8dc9,
165
+ 0x7c26, 0x6c07, 0x5c64, 0x4c45, 0x3ca2, 0x2c83, 0x1ce0, 0x0cc1,
166
+ 0xef1f, 0xff3e, 0xcf5d, 0xdf7c, 0xaf9b, 0xbfba, 0x8fd9, 0x9ff8,
167
+ 0x6e17, 0x7e36, 0x4e55, 0x5e74, 0x2e93, 0x3eb2, 0x0ed1, 0x1ef0
168
+ ].freeze
169
+
170
+ # This is the CRC16 algorithm used by Redis Cluster to hash keys.
171
+ # Copied from https://github.com/antirez/redis-rb-cluster/blob/master/crc16.rb
172
+ def crc16(bytes)
173
+ sum = 0
174
+
175
+ bytes.each_byte do |byte|
176
+ sum = ((sum << 8) & 0xffff) ^ XMODEM_CRC16_LOOKUP[((sum >> 8) ^ byte) & 0xff]
177
+ end
178
+
179
+ return sum
180
+ end
181
+
182
+ HASH_SLOTS = 16_384
183
+
184
+ public
185
+
186
+ # Return Redis::Client for a given key.
187
+ # Modified from https://github.com/antirez/redis-rb-cluster/blob/master/cluster.rb#L104-L117
188
+ def slot_for(key)
189
+ key = key.to_s
190
+
191
+ if s = key.index('{')
192
+ if e = key.index('}', s + 1) and e != s + 1
193
+ key = key[s + 1..e - 1]
194
+ end
195
+ end
196
+
197
+ return crc16(key) % HASH_SLOTS
198
+ end
199
+
200
+ def slots_for(keys)
201
+ slots = Hash.new{|hash, key| hash[key] = []}
202
+
203
+ keys.each do |key|
204
+ slots[slot_for(key)] << key
205
+ end
206
+
207
+ return slots
208
+ end
209
+ end
210
+ end
211
+ end
@@ -23,7 +23,11 @@ module Async
23
23
 
24
24
  def self.local(**options)
25
25
  self.new(LOCALHOST, **options)
26
- end
26
+ end
27
+
28
+ def self.remote(host, port = 6379, **options)
29
+ self.new(URI.parse("redis://#{host}:#{port}"), **options)
30
+ end
27
31
 
28
32
  SCHEMES = {
29
33
  'redis' => URI::Generic,
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2020, by David Ortiz.
5
+ # Copyright, 2023-2024, by Samuel Williams.
6
+
7
+ require_relative 'client'
8
+ require 'io/stream'
9
+
10
+ module Async
11
+ module Redis
12
+ class SentinelClient
13
+ DEFAULT_MASTER_NAME = 'mymaster'
14
+
15
+ include ::Protocol::Redis::Methods
16
+ include Client::Methods
17
+
18
+ # Create a new instance of the SentinelClient.
19
+ #
20
+ # @property endpoints [Array(Endpoint)] The list of sentinel endpoints.
21
+ # @property master_name [String] The name of the master instance, defaults to 'mymaster'.
22
+ # @property role [Symbol] The role of the instance that you want to connect to, either `:master` or `:slave`.
23
+ # @property protocol [Protocol] The protocol to use when connecting to the actual Redis server, defaults to {Protocol::RESP2}.
24
+ def initialize(endpoints, master_name: DEFAULT_MASTER_NAME, role: :master, protocol: Protocol::RESP2, **options)
25
+ @endpoints = endpoints
26
+ @master_name = master_name
27
+ @role = role
28
+ @protocol = protocol
29
+
30
+ # A cache of sentinel connections.
31
+ @sentinels = {}
32
+
33
+ @pool = connect(**options)
34
+ end
35
+
36
+ # @attribute [String] The name of the master instance.
37
+ attr :master_name
38
+
39
+ # @attribute [Symbol] The role of the instance that you want to connect to.
40
+ attr :role
41
+
42
+ def resolve_address(role = @role)
43
+ case role
44
+ when :master
45
+ resolve_master
46
+ when :slave
47
+ resolve_slave
48
+ else
49
+ raise ArgumentError, "Unknown instance role #{role}"
50
+ end => address
51
+
52
+ Console.debug(self, "Resolved #{@role} address: #{address}")
53
+
54
+ address or raise RuntimeError, "Unable to fetch #{role} via Sentinel."
55
+ end
56
+
57
+ def close
58
+ super
59
+
60
+ @sentinels.each do |_, client|
61
+ client.close
62
+ end
63
+ end
64
+
65
+ def failover(name = @master_name)
66
+ sentinels do |client|
67
+ return client.call('SENTINEL', 'FAILOVER', name)
68
+ end
69
+ end
70
+
71
+ def masters
72
+ sentinels do |client|
73
+ return client.call('SENTINEL', 'MASTERS').map{|fields| fields.each_slice(2).to_h}
74
+ end
75
+ end
76
+
77
+ def master(name = @master_name)
78
+ sentinels do |client|
79
+ return client.call('SENTINEL', 'MASTER', name).each_slice(2).to_h
80
+ end
81
+ end
82
+
83
+ def resolve_master
84
+ sentinels do |client|
85
+ begin
86
+ address = client.call('SENTINEL', 'GET-MASTER-ADDR-BY-NAME', @master_name)
87
+ rescue Errno::ECONNREFUSED
88
+ next
89
+ end
90
+
91
+ return Endpoint.remote(address[0], address[1]) if address
92
+ end
93
+
94
+ return nil
95
+ end
96
+
97
+ def resolve_slave
98
+ sentinels do |client|
99
+ begin
100
+ reply = client.call('SENTINEL', 'SLAVES', @master_name)
101
+ rescue Errno::ECONNREFUSED
102
+ next
103
+ end
104
+
105
+ slaves = available_slaves(reply)
106
+ next if slaves.empty?
107
+
108
+ slave = select_slave(slaves)
109
+ return Endpoint.remote(slave['ip'], slave['port'])
110
+ end
111
+
112
+ return nil
113
+ end
114
+
115
+ protected
116
+
117
+ # Override the parent method. The only difference is that this one needs to resolve the master/slave address.
118
+ def connect(**options)
119
+ Async::Pool::Controller.wrap(**options) do
120
+ endpoint = resolve_address
121
+ peer = endpoint.connect
122
+ stream = ::IO::Stream(peer)
123
+
124
+ @protocol.client(stream)
125
+ end
126
+ end
127
+
128
+ def sentinels
129
+ @endpoints.map do |endpoint|
130
+ @sentinels[endpoint] ||= Client.new(endpoint)
131
+
132
+ yield @sentinels[endpoint]
133
+ end
134
+ end
135
+
136
+ def available_slaves(reply)
137
+ # The reply is an array with the format: [field1, value1, field2,
138
+ # value2, etc.].
139
+ # When a slave is marked as down by the sentinel, the "flags" field
140
+ # (comma-separated array) contains the "s_down" value.
141
+ slaves = reply.map{|fields| fields.each_slice(2).to_h}
142
+
143
+ slaves.reject do |slave|
144
+ slave['flags'].split(',').include?('s_down')
145
+ end
146
+ end
147
+
148
+ def select_slave(available_slaves)
149
+ available_slaves.sample
150
+ end
151
+ end
152
+ end
153
+ end
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Async
7
7
  module Redis
8
- VERSION = "0.9.0"
8
+ VERSION = "0.10.0"
9
9
  end
10
10
  end
data/lib/async/redis.rb CHANGED
@@ -6,4 +6,6 @@
6
6
 
7
7
  require_relative 'redis/version'
8
8
  require_relative 'redis/client'
9
- require_relative 'redis/sentinels'
9
+
10
+ require_relative 'redis/cluster_client'
11
+ require_relative 'redis/sentinel_client'
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-redis
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -128,6 +128,7 @@ extra_rdoc_files: []
128
128
  files:
129
129
  - lib/async/redis.rb
130
130
  - lib/async/redis/client.rb
131
+ - lib/async/redis/cluster_client.rb
131
132
  - lib/async/redis/context/generic.rb
132
133
  - lib/async/redis/context/pipeline.rb
133
134
  - lib/async/redis/context/subscribe.rb
@@ -137,7 +138,7 @@ files:
137
138
  - lib/async/redis/protocol/authenticated.rb
138
139
  - lib/async/redis/protocol/resp2.rb
139
140
  - lib/async/redis/protocol/selected.rb
140
- - lib/async/redis/sentinels.rb
141
+ - lib/async/redis/sentinel_client.rb
141
142
  - lib/async/redis/version.rb
142
143
  - license.md
143
144
  - readme.md
metadata.gz.sig CHANGED
Binary file
@@ -1,104 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Released under the MIT License.
4
- # Copyright, 2020, by David Ortiz.
5
- # Copyright, 2023-2024, by Samuel Williams.
6
-
7
- require 'io/stream'
8
-
9
- module Async
10
- module Redis
11
- class SentinelsClient < Client
12
- def initialize(master_name, sentinels, role = :master, protocol = Protocol::RESP2, **options)
13
- @master_name = master_name
14
-
15
- @sentinel_endpoints = sentinels.map do |sentinel|
16
- ::IO::Endpoint.tcp(sentinel[:host], sentinel[:port])
17
- end
18
-
19
- @role = role
20
- @protocol = protocol
21
- @pool = connect(**options)
22
- end
23
-
24
- private
25
-
26
- # Override the parent method. The only difference is that this one needs
27
- # to resolve the master/slave address.
28
- def connect(**options)
29
- Async::Pool::Controller.wrap(**options) do
30
- endpoint = resolve_address
31
- peer = endpoint.connect
32
- stream = ::IO::Stream(peer)
33
-
34
- @protocol.client(stream)
35
- end
36
- end
37
-
38
- def resolve_address
39
- case @role
40
- when :master
41
- resolve_master
42
- when :slave
43
- resolve_slave
44
- else
45
- raise ArgumentError, "Unknown instance role #{@role}"
46
- end => address
47
-
48
- address or raise RuntimeError, "Unable to fetch #{@role} via Sentinel."
49
- end
50
-
51
- def resolve_master
52
- @sentinel_endpoints.each do |sentinel_endpoint|
53
- client = Client.new(sentinel_endpoint, protocol: @protocol)
54
-
55
- begin
56
- address = client.call('sentinel', 'get-master-addr-by-name', @master_name)
57
- rescue Errno::ECONNREFUSED
58
- next
59
- end
60
-
61
- return ::IO::Endpoint.tcp(address[0], address[1]) if address
62
- end
63
-
64
- nil
65
- end
66
-
67
- def resolve_slave
68
- @sentinel_endpoints.each do |sentinel_endpoint|
69
- client = Client.new(sentinel_endpoint, protocol: @protocol)
70
-
71
- begin
72
- reply = client.call('sentinel', 'slaves', @master_name)
73
- rescue Errno::ECONNREFUSED
74
- next
75
- end
76
-
77
- slaves = available_slaves(reply)
78
- next if slaves.empty?
79
-
80
- slave = select_slave(slaves)
81
- return ::IO::Endpoint.tcp(slave['ip'], slave['port'])
82
- end
83
-
84
- nil
85
- end
86
-
87
- def available_slaves(reply)
88
- # The reply is an array with the format: [field1, value1, field2,
89
- # value2, etc.].
90
- # When a slave is marked as down by the sentinel, the "flags" field
91
- # (comma-separated array) contains the "s_down" value.
92
- slaves = reply.map{|fields| fields.each_slice(2).to_h}
93
-
94
- slaves.reject do |slave|
95
- slave['flags'].split(',').include?('s_down')
96
- end
97
- end
98
-
99
- def select_slave(available_slaves)
100
- available_slaves.sample
101
- end
102
- end
103
- end
104
- end