async-redis 0.9.0 → 0.10.0

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: 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