nogara-redis_failover 0.8.9
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.
- data/.gitignore +19 -0
- data/.travis.yml +5 -0
- data/.yardopts +6 -0
- data/Changes.md +116 -0
- data/Gemfile +2 -0
- data/LICENSE +22 -0
- data/README.md +190 -0
- data/Rakefile +9 -0
- data/bin/redis_node_manager +7 -0
- data/examples/config.yml +14 -0
- data/examples/multiple_environments_config.yml +15 -0
- data/lib/redis_failover.rb +22 -0
- data/lib/redis_failover/cli.rb +119 -0
- data/lib/redis_failover/client.rb +441 -0
- data/lib/redis_failover/errors.rb +47 -0
- data/lib/redis_failover/manual_failover.rb +40 -0
- data/lib/redis_failover/node.rb +190 -0
- data/lib/redis_failover/node_manager.rb +352 -0
- data/lib/redis_failover/node_watcher.rb +79 -0
- data/lib/redis_failover/runner.rb +28 -0
- data/lib/redis_failover/util.rb +83 -0
- data/lib/redis_failover/version.rb +3 -0
- data/misc/redis_failover.png +0 -0
- data/redis_failover.gemspec +26 -0
- data/spec/cli_spec.rb +75 -0
- data/spec/client_spec.rb +100 -0
- data/spec/node_manager_spec.rb +112 -0
- data/spec/node_spec.rb +84 -0
- data/spec/node_watcher_spec.rb +58 -0
- data/spec/spec_helper.rb +20 -0
- data/spec/support/config/multiple_environments.yml +15 -0
- data/spec/support/config/multiple_environments_with_chroot.yml +17 -0
- data/spec/support/config/single_environment.yml +7 -0
- data/spec/support/config/single_environment_with_chroot.yml +8 -0
- data/spec/support/node_manager_stub.rb +65 -0
- data/spec/support/redis_stub.rb +105 -0
- data/spec/util_spec.rb +21 -0
- metadata +210 -0
@@ -0,0 +1,79 @@
|
|
1
|
+
module RedisFailover
|
2
|
+
# NodeWatcher periodically monitors a specific redis node for its availability.
|
3
|
+
# NodeWatcher instances periodically report a redis node's current state
|
4
|
+
# to the NodeManager for proper handling.
|
5
|
+
class NodeWatcher
|
6
|
+
include Util
|
7
|
+
|
8
|
+
# Time to sleep before checking on the monitored node's status.
|
9
|
+
WATCHER_SLEEP_TIME = 2
|
10
|
+
|
11
|
+
# Creates a new instance.
|
12
|
+
#
|
13
|
+
# @param [NodeManager] manager the node manager
|
14
|
+
# @param [Node] node the node to watch
|
15
|
+
# @param [Integer] max_failures the max failues before reporting node as down
|
16
|
+
def initialize(manager, node, max_failures)
|
17
|
+
@manager = manager
|
18
|
+
@node = node
|
19
|
+
@max_failures = max_failures
|
20
|
+
@monitor_thread = nil
|
21
|
+
@done = false
|
22
|
+
end
|
23
|
+
|
24
|
+
# Starts the node watcher.
|
25
|
+
#
|
26
|
+
# @note this method returns immediately and causes monitoring to be
|
27
|
+
# performed in a new background thread
|
28
|
+
def watch
|
29
|
+
@monitor_thread ||= Thread.new { monitor_node }
|
30
|
+
self
|
31
|
+
end
|
32
|
+
|
33
|
+
# Performs a graceful shutdown of this watcher.
|
34
|
+
def shutdown
|
35
|
+
@done = true
|
36
|
+
@node.wakeup
|
37
|
+
@monitor_thread.join if @monitor_thread
|
38
|
+
rescue
|
39
|
+
# best effort
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
# Periodically monitors the redis node and reports state changes to
|
45
|
+
# the {RedisFailover::NodeManager}.
|
46
|
+
def monitor_node
|
47
|
+
failures = 0
|
48
|
+
|
49
|
+
loop do
|
50
|
+
begin
|
51
|
+
return if @done
|
52
|
+
sleep(WATCHER_SLEEP_TIME)
|
53
|
+
@node.ping
|
54
|
+
failures = 0
|
55
|
+
|
56
|
+
if @node.syncing_with_master?
|
57
|
+
notify(:syncing)
|
58
|
+
else
|
59
|
+
notify(:available)
|
60
|
+
@node.wait
|
61
|
+
end
|
62
|
+
rescue NodeUnavailableError
|
63
|
+
failures += 1
|
64
|
+
if failures >= @max_failures
|
65
|
+
notify(:unavailable)
|
66
|
+
failures = 0
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Notifies the manager of a node's state.
|
73
|
+
#
|
74
|
+
# @param [Symbol] state the node's state
|
75
|
+
def notify(state)
|
76
|
+
@manager.notify_state(@node, state)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module RedisFailover
|
2
|
+
# Runner is responsible for bootstrapping the Node Manager.
|
3
|
+
class Runner
|
4
|
+
# Launches the Node Manager in a background thread.
|
5
|
+
#
|
6
|
+
# @param [Array] options the command-line options
|
7
|
+
# @note this method blocks and does not return until the
|
8
|
+
# Node Manager is gracefully stopped
|
9
|
+
def self.run(options)
|
10
|
+
options = CLI.parse(options)
|
11
|
+
@node_manager = NodeManager.new(options)
|
12
|
+
trap_signals
|
13
|
+
node_manager_thread = Thread.new { @node_manager.start }
|
14
|
+
node_manager_thread.join
|
15
|
+
end
|
16
|
+
|
17
|
+
# Traps shutdown signals.
|
18
|
+
def self.trap_signals
|
19
|
+
[:INT, :TERM].each do |signal|
|
20
|
+
trap(signal) do
|
21
|
+
Util.logger.info('Shutting down ...')
|
22
|
+
@node_manager.shutdown
|
23
|
+
exit(0)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'redis_failover/errors'
|
2
|
+
|
3
|
+
module RedisFailover
|
4
|
+
# Common utiilty methods and constants.
|
5
|
+
module Util
|
6
|
+
extend self
|
7
|
+
|
8
|
+
# Default node in ZK that contains the current list of available redis nodes.
|
9
|
+
DEFAULT_ZNODE_PATH = '/redis_failover_nodes'.freeze
|
10
|
+
|
11
|
+
# Connectivity errors that the redis (<3.x) client raises.
|
12
|
+
REDIS_ERRORS = Errno.constants.map { |c| Errno.const_get(c) }
|
13
|
+
|
14
|
+
# Connectivity errors that the redis (>3.x) client raises.
|
15
|
+
REDIS_ERRORS << Redis::BaseError if Redis.const_defined?("BaseError")
|
16
|
+
REDIS_ERRORS.freeze
|
17
|
+
|
18
|
+
# Full set of errors related to connectivity.
|
19
|
+
CONNECTIVITY_ERRORS = [
|
20
|
+
RedisFailover::Error,
|
21
|
+
ZK::Exceptions::InterruptedSession,
|
22
|
+
REDIS_ERRORS
|
23
|
+
].flatten.freeze
|
24
|
+
|
25
|
+
# Symbolizes the keys of the specified hash.
|
26
|
+
#
|
27
|
+
# @param [Hash] hash a hash for which keys should be symbolized
|
28
|
+
# @return [Hash] a new hash with symbolized keys
|
29
|
+
def symbolize_keys(hash)
|
30
|
+
Hash[hash.map { |k, v| [k.to_sym, v] }]
|
31
|
+
end
|
32
|
+
|
33
|
+
# Determines if two arrays are different.
|
34
|
+
#
|
35
|
+
# @param [Array] ary_a the first array
|
36
|
+
# @param [Array] ary_b the second array
|
37
|
+
# @return [Boolean] true if arrays are different, false otherwise
|
38
|
+
def different?(ary_a, ary_b)
|
39
|
+
((ary_a | ary_b) - (ary_a & ary_b)).size > 0
|
40
|
+
end
|
41
|
+
|
42
|
+
# @return [Logger] the logger instance to use
|
43
|
+
def self.logger
|
44
|
+
@logger ||= begin
|
45
|
+
logger = Logger.new(STDOUT)
|
46
|
+
logger.level = Logger::INFO
|
47
|
+
logger.formatter = proc do |severity, datetime, progname, msg|
|
48
|
+
"#{datetime.utc} RedisFailover #{Process.pid} #{severity}: #{msg}\n"
|
49
|
+
end
|
50
|
+
logger
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Sets a new logger to use.
|
55
|
+
#
|
56
|
+
# @param [Logger] logger a new logger to use
|
57
|
+
def self.logger=(logger)
|
58
|
+
@logger = logger
|
59
|
+
end
|
60
|
+
|
61
|
+
# @return [Logger] the logger instance to use
|
62
|
+
def logger
|
63
|
+
Util.logger
|
64
|
+
end
|
65
|
+
|
66
|
+
# Encodes the specified data in JSON format.
|
67
|
+
#
|
68
|
+
# @param [Object] data the data to encode
|
69
|
+
# @return [String] the JSON-encoded data
|
70
|
+
def encode(data)
|
71
|
+
MultiJson.encode(data)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Decodes the specified JSON data.
|
75
|
+
#
|
76
|
+
# @param [String] data the JSON data to decode
|
77
|
+
# @return [Object] the decoded data
|
78
|
+
def decode(data)
|
79
|
+
return unless data
|
80
|
+
MultiJson.decode(data)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
Binary file
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/redis_failover/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ["Ryan LeCompte"]
|
6
|
+
gem.email = ["lecompte@gmail.com"]
|
7
|
+
gem.description = %(Redis Failover is a ZooKeeper-based automatic master/slave failover solution for Ruby)
|
8
|
+
gem.summary = %(Redis Failover is a ZooKeeper-based automatic master/slave failover solution for Ruby)
|
9
|
+
gem.homepage = "http://github.com/ryanlecompte/redis_failover"
|
10
|
+
|
11
|
+
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
12
|
+
gem.files = `git ls-files`.split("\n")
|
13
|
+
gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
14
|
+
gem.name = "nogara-redis_failover"
|
15
|
+
gem.require_paths = ["lib"]
|
16
|
+
gem.version = RedisFailover::VERSION
|
17
|
+
|
18
|
+
gem.add_dependency('redis', '~> 3')
|
19
|
+
gem.add_dependency('redis-namespace')
|
20
|
+
gem.add_dependency('multi_json', '~> 1')
|
21
|
+
gem.add_dependency('zk', '~> 1.6')
|
22
|
+
|
23
|
+
gem.add_development_dependency('rake')
|
24
|
+
gem.add_development_dependency('rspec')
|
25
|
+
gem.add_development_dependency('yard')
|
26
|
+
end
|
data/spec/cli_spec.rb
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module RedisFailover
|
4
|
+
describe CLI do
|
5
|
+
describe '.parse' do
|
6
|
+
it 'properly parses redis nodes' do
|
7
|
+
opts = CLI.parse(['-n host1:1,host2:2,host3:3', '-z localhost:1111'])
|
8
|
+
opts[:nodes].should == [
|
9
|
+
{:host => 'host1', :port => '1'},
|
10
|
+
{:host => 'host2', :port => '2'},
|
11
|
+
{:host => 'host3', :port => '3'}
|
12
|
+
]
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'properly parses ZooKeeper servers' do
|
16
|
+
opts = CLI.parse(['-n host1:1,host2:2,host3:3', '-z localhost:1111'])
|
17
|
+
opts[:zkservers].should == 'localhost:1111'
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'properly parses a redis password' do
|
21
|
+
opts = CLI.parse(['-n host:port', '-z localhost:1111', '-p redis_pass'])
|
22
|
+
opts[:nodes].should == [{
|
23
|
+
:host => 'host',
|
24
|
+
:port => 'port',
|
25
|
+
:password => 'redis_pass'
|
26
|
+
}]
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'properly parses max node failures' do
|
30
|
+
opts = CLI.parse([
|
31
|
+
'-n host:port',
|
32
|
+
'-z localhost:1111',
|
33
|
+
'-p redis_pass',
|
34
|
+
'--max-failures',
|
35
|
+
'1'])
|
36
|
+
opts[:max_failures].should == 1
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'properly parses a chroot' do
|
40
|
+
opts = CLI.parse(['-n host:port', '-z localhost:1111', '--with-chroot', '/with/chroot/from/command/line'])
|
41
|
+
opts[:nodes].should == [{
|
42
|
+
:host => 'host',
|
43
|
+
:port => 'port',
|
44
|
+
}]
|
45
|
+
|
46
|
+
opts[:chroot].should == '/with/chroot/from/command/line'
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'properly parses the config file' do
|
50
|
+
opts = CLI.parse(['-C', "#{File.dirname(__FILE__)}/support/config/single_environment.yml"])
|
51
|
+
opts[:zkservers].should == 'zk01:2181,zk02:2181,zk03:2181'
|
52
|
+
|
53
|
+
opts = CLI.parse(['-C', "#{File.dirname(__FILE__)}/support/config/multiple_environments.yml", '-E', 'development'])
|
54
|
+
opts[:zkservers].should == 'localhost:2181'
|
55
|
+
|
56
|
+
opts = CLI.parse(['-C', "#{File.dirname(__FILE__)}/support/config/multiple_environments.yml", '-E', 'staging'])
|
57
|
+
opts[:zkservers].should == 'zk01:2181,zk02:2181,zk03:2181'
|
58
|
+
end
|
59
|
+
|
60
|
+
it 'properly parses the config file that include chroot' do
|
61
|
+
opts = CLI.parse(['-C', "#{File.dirname(__FILE__)}/support/config/single_environment_with_chroot.yml"])
|
62
|
+
opts[:zkservers].should == 'zk01:2181,zk02:2181,zk03:2181'
|
63
|
+
opts[:chroot].should == '/with/chroot'
|
64
|
+
|
65
|
+
opts = CLI.parse(['-C', "#{File.dirname(__FILE__)}/support/config/multiple_environments_with_chroot.yml", '-E', 'development'])
|
66
|
+
opts[:zkservers].should == 'localhost:2181'
|
67
|
+
opts[:chroot].should == '/with/chroot_development'
|
68
|
+
|
69
|
+
opts = CLI.parse(['-C', "#{File.dirname(__FILE__)}/support/config/multiple_environments_with_chroot.yml", '-E', 'staging'])
|
70
|
+
opts[:zkservers].should == 'zk01:2181,zk02:2181,zk03:2181'
|
71
|
+
opts[:chroot].should == '/with/chroot_staging'
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
data/spec/client_spec.rb
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module RedisFailover
|
4
|
+
Client::Redis = RedisStub
|
5
|
+
class ClientStub < Client
|
6
|
+
def current_master
|
7
|
+
@master
|
8
|
+
end
|
9
|
+
|
10
|
+
def current_slaves
|
11
|
+
@slaves
|
12
|
+
end
|
13
|
+
|
14
|
+
def fetch_nodes
|
15
|
+
{
|
16
|
+
:master => 'localhost:6379',
|
17
|
+
:slaves => ['localhost:1111'],
|
18
|
+
:unavailable => []
|
19
|
+
}
|
20
|
+
end
|
21
|
+
|
22
|
+
def setup_zk
|
23
|
+
@zk = NullObject.new
|
24
|
+
update_znode_timestamp
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
describe Client do
|
29
|
+
let(:client) { ClientStub.new(:zkservers => 'localhost:9281') }
|
30
|
+
|
31
|
+
describe '#build_clients' do
|
32
|
+
it 'properly parses master' do
|
33
|
+
client.current_master.to_s.should == 'localhost:6379'
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'properly parses slaves' do
|
37
|
+
client.current_slaves.first.to_s.should == 'localhost:1111'
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
describe '#dispatch' do
|
42
|
+
it 'routes write operations to master' do
|
43
|
+
called = false
|
44
|
+
client.current_master.define_singleton_method(:del) do |*args|
|
45
|
+
called = true
|
46
|
+
end
|
47
|
+
client.del('foo')
|
48
|
+
called.should be_true
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'routes read operations to a slave' do
|
52
|
+
called = false
|
53
|
+
client.current_slaves.first.change_role_to('slave')
|
54
|
+
client.current_slaves.first.define_singleton_method(:get) do |*args|
|
55
|
+
called = true
|
56
|
+
end
|
57
|
+
client.get('foo')
|
58
|
+
called.should be_true
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'reconnects when node is unavailable' do
|
62
|
+
class << client
|
63
|
+
attr_reader :reconnected
|
64
|
+
def build_clients
|
65
|
+
@reconnected = true
|
66
|
+
super
|
67
|
+
end
|
68
|
+
|
69
|
+
def fetch_nodes
|
70
|
+
@calls ||= 0
|
71
|
+
{
|
72
|
+
:master => "localhost:222#{@calls += 1}",
|
73
|
+
:slaves => ['localhost:1111'],
|
74
|
+
:unavailable => []
|
75
|
+
}
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
client.current_master.make_unavailable!
|
80
|
+
client.del('foo')
|
81
|
+
client.reconnected.should be_true
|
82
|
+
end
|
83
|
+
|
84
|
+
it 'properly detects when a node has changed roles' do
|
85
|
+
client.current_master.change_role_to('slave')
|
86
|
+
expect { client.send(:master) }.to raise_error(InvalidNodeRoleError)
|
87
|
+
end
|
88
|
+
|
89
|
+
it 'raises error for unsupported operations' do
|
90
|
+
expect { client.select }.to raise_error(UnsupportedOperationError)
|
91
|
+
end
|
92
|
+
|
93
|
+
it 'attempts ZK reconnect when no communication from Node Manager within certain time window' do
|
94
|
+
client.instance_variable_set(:@last_znode_timestamp, Time.at(0))
|
95
|
+
client.should_receive(:build_clients)
|
96
|
+
client.del('foo')
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module RedisFailover
|
4
|
+
describe NodeManager do
|
5
|
+
let(:manager) { NodeManagerStub.new({}) }
|
6
|
+
|
7
|
+
before(:each) do
|
8
|
+
manager.discover_nodes
|
9
|
+
end
|
10
|
+
|
11
|
+
describe '#nodes' do
|
12
|
+
it 'returns current master and slave nodes' do
|
13
|
+
manager.current_nodes.should == {
|
14
|
+
:master => 'master:6379',
|
15
|
+
:slaves => ['slave:6379'],
|
16
|
+
:unavailable => []
|
17
|
+
}
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
describe '#handle_unavailable' do
|
22
|
+
context 'slave dies' do
|
23
|
+
it 'moves slave to unavailable list' do
|
24
|
+
slave = manager.slaves.first
|
25
|
+
manager.force_unavailable(slave)
|
26
|
+
manager.current_nodes[:unavailable].should include(slave.to_s)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
context 'master dies' do
|
31
|
+
before(:each) do
|
32
|
+
@slave = manager.slaves.first
|
33
|
+
@master = manager.master
|
34
|
+
manager.force_unavailable(@master)
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'promotes slave to master' do
|
38
|
+
manager.master.should == @slave
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'moves master to unavailable list' do
|
42
|
+
manager.current_nodes[:unavailable].should include(@master.to_s)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
describe '#handle_available' do
|
48
|
+
before(:each) do
|
49
|
+
# force to be unavailable first
|
50
|
+
@slave = manager.slaves.first
|
51
|
+
manager.force_unavailable(@slave)
|
52
|
+
end
|
53
|
+
|
54
|
+
context 'slave node with a master present' do
|
55
|
+
it 'removes slave from unavailable list' do
|
56
|
+
manager.force_available(@slave)
|
57
|
+
manager.current_nodes[:unavailable].should be_empty
|
58
|
+
manager.current_nodes[:slaves].should include(@slave.to_s)
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'makes node a slave of new master' do
|
62
|
+
manager.master = Node.new(:host => 'foo', :port => '7892')
|
63
|
+
manager.force_available(@slave)
|
64
|
+
@slave.fetch_info.should == {
|
65
|
+
:role => 'slave',
|
66
|
+
:master_host => 'foo',
|
67
|
+
:master_port => '7892'}
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'does not invoke slaveof operation if master has not changed' do
|
71
|
+
@slave.redis.should_not_receive(:slaveof)
|
72
|
+
manager.force_available(@slave)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
context 'slave node with no master present' do
|
77
|
+
before(:each) do
|
78
|
+
@master = manager.master
|
79
|
+
manager.force_unavailable(@master)
|
80
|
+
end
|
81
|
+
|
82
|
+
it 'promotes slave to master' do
|
83
|
+
manager.force_available(@slave)
|
84
|
+
manager.master.should == @slave
|
85
|
+
end
|
86
|
+
|
87
|
+
it 'slaves list remains empty' do
|
88
|
+
manager.current_nodes[:slaves].should be_empty
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
describe '#handle_syncing' do
|
94
|
+
context 'prohibits stale reads' do
|
95
|
+
it 'adds node to unavailable list' do
|
96
|
+
slave = manager.slaves.first
|
97
|
+
manager.force_syncing(slave, false)
|
98
|
+
manager.current_nodes[:unavailable].should include(slave.to_s)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
context 'allows stale reads' do
|
103
|
+
it 'makes node available' do
|
104
|
+
slave = manager.slaves.first
|
105
|
+
manager.force_syncing(slave, true)
|
106
|
+
manager.current_nodes[:unavailable].should_not include(slave.to_s)
|
107
|
+
manager.current_nodes[:slaves].should include(slave.to_s)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|