evinrude 0.0.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.
- checksums.yaml +7 -0
- data/.editorconfig +23 -0
- data/.gitignore +6 -0
- data/.yardopts +1 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/CONTRIBUTING.md +10 -0
- data/LICENCE +674 -0
- data/README.md +410 -0
- data/evinrude.gemspec +42 -0
- data/lib/evinrude.rb +1233 -0
- data/lib/evinrude/backoff.rb +19 -0
- data/lib/evinrude/cluster_configuration.rb +162 -0
- data/lib/evinrude/config_change_queue_entry.rb +19 -0
- data/lib/evinrude/config_change_queue_entry/add_node.rb +13 -0
- data/lib/evinrude/config_change_queue_entry/remove_node.rb +14 -0
- data/lib/evinrude/freedom_patches/range.rb +5 -0
- data/lib/evinrude/log.rb +102 -0
- data/lib/evinrude/log_entries.rb +3 -0
- data/lib/evinrude/log_entry.rb +13 -0
- data/lib/evinrude/log_entry/cluster_configuration.rb +15 -0
- data/lib/evinrude/log_entry/null.rb +6 -0
- data/lib/evinrude/log_entry/state_machine_command.rb +13 -0
- data/lib/evinrude/logging_helpers.rb +40 -0
- data/lib/evinrude/message.rb +19 -0
- data/lib/evinrude/message/append_entries_reply.rb +13 -0
- data/lib/evinrude/message/append_entries_request.rb +18 -0
- data/lib/evinrude/message/command_reply.rb +13 -0
- data/lib/evinrude/message/command_request.rb +18 -0
- data/lib/evinrude/message/install_snapshot_reply.rb +13 -0
- data/lib/evinrude/message/install_snapshot_request.rb +18 -0
- data/lib/evinrude/message/join_reply.rb +13 -0
- data/lib/evinrude/message/join_request.rb +18 -0
- data/lib/evinrude/message/node_removal_reply.rb +13 -0
- data/lib/evinrude/message/node_removal_request.rb +18 -0
- data/lib/evinrude/message/read_reply.rb +13 -0
- data/lib/evinrude/message/read_request.rb +18 -0
- data/lib/evinrude/message/vote_reply.rb +13 -0
- data/lib/evinrude/message/vote_request.rb +18 -0
- data/lib/evinrude/messages.rb +14 -0
- data/lib/evinrude/metrics.rb +50 -0
- data/lib/evinrude/network.rb +69 -0
- data/lib/evinrude/network/connection.rb +144 -0
- data/lib/evinrude/network/protocol.rb +69 -0
- data/lib/evinrude/node_info.rb +35 -0
- data/lib/evinrude/peer.rb +50 -0
- data/lib/evinrude/resolver.rb +96 -0
- data/lib/evinrude/snapshot.rb +9 -0
- data/lib/evinrude/state_machine.rb +15 -0
- data/lib/evinrude/state_machine/register.rb +25 -0
- data/smoke_tests/001_single_node_cluster.rb +20 -0
- data/smoke_tests/002_three_node_cluster.rb +43 -0
- data/smoke_tests/003_spill.rb +25 -0
- data/smoke_tests/004_stale_read.rb +67 -0
- data/smoke_tests/005_sleepy_master.rb +28 -0
- data/smoke_tests/006_join_via_follower.rb +26 -0
- data/smoke_tests/007_snapshot_madness.rb +97 -0
- data/smoke_tests/008_downsizing.rb +43 -0
- data/smoke_tests/009_disaster_recovery.rb +46 -0
- data/smoke_tests/999_final_smoke_test.rb +279 -0
- data/smoke_tests/run +22 -0
- data/smoke_tests/smoke_test_helper.rb +199 -0
- metadata +318 -0
@@ -0,0 +1,69 @@
|
|
1
|
+
require "rbnacl"
|
2
|
+
|
3
|
+
class Evinrude
|
4
|
+
class Network
|
5
|
+
module Protocol
|
6
|
+
class VersionError < Error; end
|
7
|
+
class DecryptionError < Error; end
|
8
|
+
|
9
|
+
class MessageTooBigError < Error; end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def max_message_size
|
14
|
+
@max_message_size ||= begin
|
15
|
+
if File.exist?("/proc/meminfo")
|
16
|
+
File.read("/proc/meminfo").split("\n").map do |l|
|
17
|
+
if l =~ /^MemTotal:\s+(\d+)\s+kB/
|
18
|
+
$1.to_i * 1024 / 10
|
19
|
+
else
|
20
|
+
nil
|
21
|
+
end
|
22
|
+
end.compact.first
|
23
|
+
else
|
24
|
+
100_000_000
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def box(msg)
|
30
|
+
logger.debug(logloc) { "Encrypting message #{msg.inspect} with key #{@keys.first.inspect}" }
|
31
|
+
RbNaCl::SimpleBox.from_secret_key(@keys.first).encrypt(msg)
|
32
|
+
end
|
33
|
+
|
34
|
+
def frame(chunk)
|
35
|
+
"\x00" + encode_length(chunk.length) + chunk
|
36
|
+
end
|
37
|
+
|
38
|
+
def unbox(ciphertext)
|
39
|
+
@keys.each do |k|
|
40
|
+
begin
|
41
|
+
return RbNaCl::SimpleBox.from_secret_key(k).decrypt(ciphertext)
|
42
|
+
rescue RbNaCl::CryptoError
|
43
|
+
# This just means "key failed"; nothing to get upset about
|
44
|
+
logger.debug(logloc) { "Decryption of #{ciphertext.inspect} failed with key #{k.inspect}" }
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# ... but if all the keys fail, *that's* something to get upset about
|
49
|
+
raise DecryptionError,
|
50
|
+
"Received an undecryptable message from #{peer_info}: #{ciphertext[0, 20].inspect}..."
|
51
|
+
end
|
52
|
+
|
53
|
+
def encode_length(i)
|
54
|
+
if i < 128
|
55
|
+
return i.chr
|
56
|
+
else
|
57
|
+
s = ""
|
58
|
+
|
59
|
+
while i > 0
|
60
|
+
s << (i % 256).chr
|
61
|
+
i /= 256
|
62
|
+
end
|
63
|
+
|
64
|
+
(0x80 + s.length).chr + s.reverse
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
class Evinrude
|
2
|
+
class NodeInfo
|
3
|
+
attr_reader :name, :address, :port
|
4
|
+
|
5
|
+
def initialize(address:, port:, name:)
|
6
|
+
@address, @port, @name = address, port, name
|
7
|
+
end
|
8
|
+
|
9
|
+
# It's useful for a NodeInfo to be able to be treated like a hash sometimes.
|
10
|
+
def [](k)
|
11
|
+
case k
|
12
|
+
when :address
|
13
|
+
@address
|
14
|
+
when :port
|
15
|
+
@port
|
16
|
+
when :name
|
17
|
+
@name
|
18
|
+
else
|
19
|
+
raise ArgumentError, "Invalid key #k.inspect}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def eql?(o)
|
24
|
+
# I know hash equality doesn't mean "equality of hashes", but it amuses
|
25
|
+
# me nonetheless that that is a good way to implement it.
|
26
|
+
[Evinrude::NodeInfo, Hash].include?(o.class) && { address: @address, port: @port, name: @name}.eql?({ address: o[:address], port: o[:port], name: o[:name] })
|
27
|
+
end
|
28
|
+
|
29
|
+
alias :== :eql?
|
30
|
+
|
31
|
+
def hash
|
32
|
+
{ address: @address, port: @port, name: @name }.hash
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
require_relative "./message/append_entries_request"
|
2
|
+
|
3
|
+
class Evinrude
|
4
|
+
class Peer
|
5
|
+
attr_reader :conn, :next_index, :match_index, :node_info
|
6
|
+
|
7
|
+
def initialize(conn:, next_index:, node_info:, metrics:)
|
8
|
+
@conn, @next_index, @node_info, @metrics = conn, next_index, node_info, metrics
|
9
|
+
@match_index = 0
|
10
|
+
|
11
|
+
update_metrics
|
12
|
+
end
|
13
|
+
|
14
|
+
def failed_append(last_index = nil)
|
15
|
+
if last_index
|
16
|
+
@next_index = last_index + 1
|
17
|
+
else
|
18
|
+
@next_index = [1, @next_index - 1].max
|
19
|
+
end
|
20
|
+
|
21
|
+
update_metrics
|
22
|
+
end
|
23
|
+
|
24
|
+
def successful_append(last_index)
|
25
|
+
@next_index = last_index + 1
|
26
|
+
@match_index = last_index
|
27
|
+
|
28
|
+
update_metrics
|
29
|
+
end
|
30
|
+
|
31
|
+
def peer_info
|
32
|
+
conn.peer_info
|
33
|
+
end
|
34
|
+
|
35
|
+
def node_name
|
36
|
+
@node_info.name
|
37
|
+
end
|
38
|
+
|
39
|
+
def rpc(*a)
|
40
|
+
conn.rpc(*a)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def update_metrics
|
46
|
+
@metrics.next_index.set(@next_index, labels: { peer: peer_info, node_name: @node_name })
|
47
|
+
@metrics.match_index.set(0, labels: { peer: peer_info, node_name: @node_name })
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require "async/dns"
|
2
|
+
require "async/dns/system"
|
3
|
+
|
4
|
+
class Evinrude
|
5
|
+
class Resolver
|
6
|
+
def initialize
|
7
|
+
@config = Config.new
|
8
|
+
p @config
|
9
|
+
@resolver = Async::DNS::Resolver.new(Async::DNS::System.standard_connections(@config.nameservers))
|
10
|
+
end
|
11
|
+
|
12
|
+
def getaddresses(name)
|
13
|
+
(getresources(name, Resolv::DNS::Resource::IN::AAAA) + getresources(name, Resolv::DNS::Resource::IN::A)).map(&:address).map(&:to_s)
|
14
|
+
end
|
15
|
+
|
16
|
+
def getresources(name, rtype)
|
17
|
+
search_candidates(name).each do |fqdn|
|
18
|
+
response = @resolver.query(fqdn, rtype)
|
19
|
+
|
20
|
+
if response.rcode != Resolv::DNS::RCode::NoError
|
21
|
+
next
|
22
|
+
end
|
23
|
+
|
24
|
+
return response.answer.map { |rr| rr[2] }
|
25
|
+
end
|
26
|
+
|
27
|
+
[]
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def search_candidates(name)
|
33
|
+
ndots = name.each_char.grep(".").length
|
34
|
+
|
35
|
+
if ndots >= @config.ndots
|
36
|
+
[name] + @config.search_suffixes.map { |ss| "#{name}.#{ss}" }
|
37
|
+
else
|
38
|
+
@config.search_suffixes.map { |ss| "#{name}.#{ss}" }
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
class Config
|
43
|
+
attr_reader :search_suffixes, :nameservers, :ndots
|
44
|
+
|
45
|
+
def initialize
|
46
|
+
@ndots = 1
|
47
|
+
|
48
|
+
if Object.const_defined?(:Win32)
|
49
|
+
# Taking a punt here
|
50
|
+
@search_suffixes, @nameservers = Win32::Resolv.get_resolv_info
|
51
|
+
else
|
52
|
+
if File.exist?(Async::DNS::System::RESOLV_CONF)
|
53
|
+
parse_resolv_conf(Async::DNS::System::RESOLV_CONF)
|
54
|
+
else
|
55
|
+
raise ArgumentError, "resolver config file #{Async::DNS::System::RESOLV_CONF} does not exist"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def parse_resolv_conf(filename)
|
63
|
+
@nameservers = []
|
64
|
+
|
65
|
+
File.open(filename) do |fd|
|
66
|
+
fd.each_line do |l|
|
67
|
+
case l
|
68
|
+
when /^domain\s+(.*)$/
|
69
|
+
@search_suffixes = [$1] if @search_suffixes.nil?
|
70
|
+
when /^search\s+(.*)$/
|
71
|
+
@search_suffixes = $1.split(/\s+/)
|
72
|
+
when /^nameserver\s+(.*)$/
|
73
|
+
@nameservers += $1.split(/\s+/)
|
74
|
+
when /^options\s+(.*)$/
|
75
|
+
$1.split(/\s+/).each do |opt|
|
76
|
+
if opt =~ /ndots:(\d+)/
|
77
|
+
@ndots = $1.to_i
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
if @search_suffixes.nil?
|
85
|
+
@search_suffixes = [Socket.gethostname.split(".", 2)[1].to_s]
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
private_constant :Config
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
|
95
|
+
|
96
|
+
|
@@ -0,0 +1,9 @@
|
|
1
|
+
class Evinrude
|
2
|
+
class Snapshot
|
3
|
+
attr_reader :node_name, :state, :cluster_config, :cluster_config_index, :last_term, :last_index, :last_command_ids
|
4
|
+
|
5
|
+
def initialize(node_name:, state:, cluster_config:, cluster_config_index:, last_term:, last_index:, last_command_ids:)
|
6
|
+
@node_name, @state, @cluster_config, @cluster_config_index, @last_term, @last_index, @last_command_ids = node_name, state, cluster_config, cluster_config_index, last_term, last_index, last_command_ids
|
7
|
+
end
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require_relative "../state_machine"
|
2
|
+
|
3
|
+
class Evinrude
|
4
|
+
class StateMachine
|
5
|
+
class Register < StateMachine
|
6
|
+
def initialize(**kwargs)
|
7
|
+
super
|
8
|
+
|
9
|
+
@state = kwargs.fetch(:snapshot, "")
|
10
|
+
end
|
11
|
+
|
12
|
+
def process_command(s)
|
13
|
+
@state = s
|
14
|
+
end
|
15
|
+
|
16
|
+
def current_state
|
17
|
+
@state
|
18
|
+
end
|
19
|
+
|
20
|
+
def snapshot
|
21
|
+
@state
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative "./smoke_test_helper"
|
4
|
+
|
5
|
+
require "evinrude"
|
6
|
+
|
7
|
+
Thread.current.name = "MT"
|
8
|
+
|
9
|
+
n = spawn_nodes(1).first
|
10
|
+
|
11
|
+
c = n.c
|
12
|
+
t = n.t
|
13
|
+
|
14
|
+
until c.leader?
|
15
|
+
t.join(0.001)
|
16
|
+
end
|
17
|
+
|
18
|
+
c.command("bob")
|
19
|
+
|
20
|
+
assert_equal c.state, "bob"
|
@@ -0,0 +1,43 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative "./smoke_test_helper"
|
4
|
+
|
5
|
+
require "evinrude"
|
6
|
+
|
7
|
+
Thread.current.name = "MT"
|
8
|
+
|
9
|
+
nodes = spawn_nodes(3)
|
10
|
+
|
11
|
+
n1, n2, n3 = nodes
|
12
|
+
|
13
|
+
wait_for_stability(nodes)
|
14
|
+
wait_for_consensus(nodes)
|
15
|
+
|
16
|
+
assert_equal n1.c.nodes.map(&:name).sort, nodes.map(&:c).map(&:node_name).sort
|
17
|
+
assert_equal n2.c.nodes.map(&:name).sort, nodes.map(&:c).map(&:node_name).sort
|
18
|
+
assert_equal n3.c.nodes.map(&:name).sort, nodes.map(&:c).map(&:node_name).sort
|
19
|
+
|
20
|
+
n1.c.command("bob")
|
21
|
+
|
22
|
+
wait_for_consensus(nodes)
|
23
|
+
|
24
|
+
assert_equal n1.c.state, "bob"
|
25
|
+
assert_equal n2.c.state, "bob"
|
26
|
+
assert_equal n3.c.state, "bob"
|
27
|
+
|
28
|
+
n2.c.command("fred")
|
29
|
+
|
30
|
+
wait_for_consensus(nodes)
|
31
|
+
|
32
|
+
assert_equal n1.c.state, "fred"
|
33
|
+
assert_equal n2.c.state, "fred"
|
34
|
+
assert_equal n3.c.state, "fred"
|
35
|
+
|
36
|
+
[Thread.new { n1.c.command("c1") }, Thread.new { n2.c.command("c2") }, Thread.new { n3.c.command("c3") }].each(&:join)
|
37
|
+
|
38
|
+
wait_for_consensus(nodes)
|
39
|
+
|
40
|
+
val = n1.c.state
|
41
|
+
|
42
|
+
assert_equal val, n2.c.state
|
43
|
+
assert_equal val, n3.c.state
|
@@ -0,0 +1,25 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative "./smoke_test_helper"
|
4
|
+
|
5
|
+
require "evinrude"
|
6
|
+
|
7
|
+
Thread.current.name = "MT"
|
8
|
+
|
9
|
+
nodes = spawn_nodes(5)
|
10
|
+
|
11
|
+
wait_for_stability(nodes)
|
12
|
+
wait_for_consensus(nodes)
|
13
|
+
|
14
|
+
prev_leader = nodes.find { |n| n.c.leader? }
|
15
|
+
prev_leader.t.kill
|
16
|
+
nodes.delete(prev_leader)
|
17
|
+
default_logger.info(logloc) { "Whacked leader #{prev_leader.t.name} to force election" }
|
18
|
+
|
19
|
+
wait_for_stability(nodes)
|
20
|
+
|
21
|
+
nodes.find { |n| n.c.follower? }.c.command("bob")
|
22
|
+
|
23
|
+
nodes.each do |n|
|
24
|
+
assert_equal n.c.state, "bob", n.t.name
|
25
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative "./smoke_test_helper"
|
4
|
+
|
5
|
+
require "evinrude"
|
6
|
+
|
7
|
+
Thread.current.name = "MT"
|
8
|
+
|
9
|
+
nodes = spawn_nodes(5)
|
10
|
+
|
11
|
+
wait_for_stability(nodes)
|
12
|
+
wait_for_consensus(nodes)
|
13
|
+
|
14
|
+
leader = nodes.find { |n| n.c.leader? }
|
15
|
+
|
16
|
+
leader.c.command("bob")
|
17
|
+
|
18
|
+
wait_for_consensus(nodes)
|
19
|
+
|
20
|
+
chosen_one = nodes.find { |n| n.c.follower? }
|
21
|
+
|
22
|
+
assert_equal "bob", chosen_one.c.state
|
23
|
+
|
24
|
+
victims = nodes.select { |n| n != leader && n != chosen_one }
|
25
|
+
|
26
|
+
victims.each do |v|
|
27
|
+
v.c.singleton_class.prepend(FaultInjector)
|
28
|
+
v.c.pause!(:process_append_entries_request)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Pop this in a separate thread because, if all goes well, this will block
|
32
|
+
cmd_t = Thread.new { Thread.current.name = "CT"; chosen_one.c.command("fred") }
|
33
|
+
cmd_t.abort_on_exception = true
|
34
|
+
|
35
|
+
# 640 milliseconds ought to be enough for anyone!
|
36
|
+
cmd_t.join(0.64)
|
37
|
+
|
38
|
+
assert_equal cmd_t.status, "sleep", "Command thread initial wait"
|
39
|
+
|
40
|
+
# This should also block
|
41
|
+
st_t = Thread.new { Thread.current.name = "ST"; chosen_one.c.state }
|
42
|
+
st_t.abort_on_exception = true
|
43
|
+
|
44
|
+
st_t.join(0.64)
|
45
|
+
|
46
|
+
assert_equal cmd_t.status, "sleep", "Command thread re-check"
|
47
|
+
assert_equal st_t.status, "sleep", "State retrieval thread"
|
48
|
+
|
49
|
+
# The fact that both of those threads are still waiting patiently indicates
|
50
|
+
# that the leader is being queried, and yet it hasn't achieved consensus
|
51
|
+
# (because a majority of the cluster is unresponsive). Now let's open up
|
52
|
+
# the floodgates and see what comes out!
|
53
|
+
|
54
|
+
victims.each { |v| v.c.unpause!(:process_append_entries_request) }
|
55
|
+
|
56
|
+
cmd_t.join(10)
|
57
|
+
|
58
|
+
assert_equal cmd_t.status, false, "Command thread completed"
|
59
|
+
|
60
|
+
nodes.each do |n|
|
61
|
+
assert_equal "fred", n.c.state, n.t.name
|
62
|
+
end
|
63
|
+
|
64
|
+
st_t.join(10)
|
65
|
+
|
66
|
+
assert_equal st_t.status, false, "State thread completed"
|
67
|
+
assert_equal st_t.value, "fred", "State thread gives new value"
|