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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +23 -0
  3. data/.gitignore +6 -0
  4. data/.yardopts +1 -0
  5. data/CODE_OF_CONDUCT.md +49 -0
  6. data/CONTRIBUTING.md +10 -0
  7. data/LICENCE +674 -0
  8. data/README.md +410 -0
  9. data/evinrude.gemspec +42 -0
  10. data/lib/evinrude.rb +1233 -0
  11. data/lib/evinrude/backoff.rb +19 -0
  12. data/lib/evinrude/cluster_configuration.rb +162 -0
  13. data/lib/evinrude/config_change_queue_entry.rb +19 -0
  14. data/lib/evinrude/config_change_queue_entry/add_node.rb +13 -0
  15. data/lib/evinrude/config_change_queue_entry/remove_node.rb +14 -0
  16. data/lib/evinrude/freedom_patches/range.rb +5 -0
  17. data/lib/evinrude/log.rb +102 -0
  18. data/lib/evinrude/log_entries.rb +3 -0
  19. data/lib/evinrude/log_entry.rb +13 -0
  20. data/lib/evinrude/log_entry/cluster_configuration.rb +15 -0
  21. data/lib/evinrude/log_entry/null.rb +6 -0
  22. data/lib/evinrude/log_entry/state_machine_command.rb +13 -0
  23. data/lib/evinrude/logging_helpers.rb +40 -0
  24. data/lib/evinrude/message.rb +19 -0
  25. data/lib/evinrude/message/append_entries_reply.rb +13 -0
  26. data/lib/evinrude/message/append_entries_request.rb +18 -0
  27. data/lib/evinrude/message/command_reply.rb +13 -0
  28. data/lib/evinrude/message/command_request.rb +18 -0
  29. data/lib/evinrude/message/install_snapshot_reply.rb +13 -0
  30. data/lib/evinrude/message/install_snapshot_request.rb +18 -0
  31. data/lib/evinrude/message/join_reply.rb +13 -0
  32. data/lib/evinrude/message/join_request.rb +18 -0
  33. data/lib/evinrude/message/node_removal_reply.rb +13 -0
  34. data/lib/evinrude/message/node_removal_request.rb +18 -0
  35. data/lib/evinrude/message/read_reply.rb +13 -0
  36. data/lib/evinrude/message/read_request.rb +18 -0
  37. data/lib/evinrude/message/vote_reply.rb +13 -0
  38. data/lib/evinrude/message/vote_request.rb +18 -0
  39. data/lib/evinrude/messages.rb +14 -0
  40. data/lib/evinrude/metrics.rb +50 -0
  41. data/lib/evinrude/network.rb +69 -0
  42. data/lib/evinrude/network/connection.rb +144 -0
  43. data/lib/evinrude/network/protocol.rb +69 -0
  44. data/lib/evinrude/node_info.rb +35 -0
  45. data/lib/evinrude/peer.rb +50 -0
  46. data/lib/evinrude/resolver.rb +96 -0
  47. data/lib/evinrude/snapshot.rb +9 -0
  48. data/lib/evinrude/state_machine.rb +15 -0
  49. data/lib/evinrude/state_machine/register.rb +25 -0
  50. data/smoke_tests/001_single_node_cluster.rb +20 -0
  51. data/smoke_tests/002_three_node_cluster.rb +43 -0
  52. data/smoke_tests/003_spill.rb +25 -0
  53. data/smoke_tests/004_stale_read.rb +67 -0
  54. data/smoke_tests/005_sleepy_master.rb +28 -0
  55. data/smoke_tests/006_join_via_follower.rb +26 -0
  56. data/smoke_tests/007_snapshot_madness.rb +97 -0
  57. data/smoke_tests/008_downsizing.rb +43 -0
  58. data/smoke_tests/009_disaster_recovery.rb +46 -0
  59. data/smoke_tests/999_final_smoke_test.rb +279 -0
  60. data/smoke_tests/run +22 -0
  61. data/smoke_tests/smoke_test_helper.rb +199 -0
  62. metadata +318 -0
@@ -0,0 +1,13 @@
1
+ require_relative "../message"
2
+
3
+ class Evinrude
4
+ class Message
5
+ class AppendEntriesReply < Message
6
+ attr_reader :term, :success, :last_index
7
+
8
+ def initialize(term:, success:, last_index: nil)
9
+ @term, @success, @last_index = term, success, last_index
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,18 @@
1
+ require_relative "../message"
2
+ require_relative "./append_entries_reply"
3
+
4
+ class Evinrude
5
+ class Message
6
+ class AppendEntriesRequest < Message
7
+ attr_reader :term, :leader_info, :leader_commit, :prev_log_index, :prev_log_term, :entries
8
+
9
+ def initialize(term:, leader_info:, leader_commit:, prev_log_index:, prev_log_term:, entries:)
10
+ @term, @leader_info, @leader_commit, @prev_log_index, @prev_log_term, @entries = term, leader_info, leader_commit, prev_log_index, prev_log_term, entries
11
+ end
12
+
13
+ def expected_reply_types
14
+ [AppendEntriesReply]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "../message"
2
+
3
+ class Evinrude
4
+ class Message
5
+ class CommandReply < Message
6
+ attr_reader :success, :leader_info
7
+
8
+ def initialize(success:, leader_info: nil)
9
+ @success, @leader_info = success, leader_info
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,18 @@
1
+ require_relative "../message"
2
+ require_relative "./command_reply"
3
+
4
+ class Evinrude
5
+ class Message
6
+ class CommandRequest < Message
7
+ attr_reader :command, :id, :node_name
8
+
9
+ def initialize(command:, id:, node_name:)
10
+ @command, @id, @node_name = command, id, node_name
11
+ end
12
+
13
+ def expected_reply_types
14
+ [CommandReply]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "../message"
2
+
3
+ class Evinrude
4
+ class Message
5
+ class InstallSnapshotReply < Message
6
+ attr_reader :term
7
+
8
+ def initialize(term:)
9
+ @term = term
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,18 @@
1
+ require_relative "../message"
2
+ require_relative "./install_snapshot_reply"
3
+
4
+ class Evinrude
5
+ class Message
6
+ class InstallSnapshotRequest < Message
7
+ attr_reader :term, :leader_info, :last_included_index, :last_included_term, :data
8
+
9
+ def initialize(term:, leader_info:, last_included_index:, last_included_term:, data:)
10
+ @term, @leader_info, @last_included_index, @last_included_term, @data = term, leader_info, last_included_index, last_included_term, data
11
+ end
12
+
13
+ def expected_reply_types
14
+ [InstallSnapshotReply]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "../message"
2
+
3
+ class Evinrude
4
+ class Message
5
+ class JoinReply < Message
6
+ attr_reader :success, :leader_info
7
+
8
+ def initialize(success:, leader_info: nil)
9
+ @success, @leader_info = success, leader_info
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,18 @@
1
+ require_relative "../message"
2
+ require_relative "./join_reply"
3
+
4
+ class Evinrude
5
+ class Message
6
+ class JoinRequest < Message
7
+ attr_reader :node_info
8
+
9
+ def initialize(node_info:)
10
+ @node_info = node_info
11
+ end
12
+
13
+ def expected_reply_types
14
+ [JoinReply]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "../message"
2
+
3
+ class Evinrude
4
+ class Message
5
+ class NodeRemovalReply < Message
6
+ attr_reader :success, :leader_info
7
+
8
+ def initialize(success:, leader_info: nil)
9
+ @success, @leader_info = success, leader_info
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,18 @@
1
+ require_relative "../message"
2
+ require_relative "./node_removal_reply"
3
+
4
+ class Evinrude
5
+ class Message
6
+ class NodeRemovalRequest < Message
7
+ attr_reader :node_info, :unsafe
8
+
9
+ def initialize(node_info:, unsafe: false)
10
+ @node_info, @unsafe = node_info, unsafe
11
+ end
12
+
13
+ def expected_reply_types
14
+ [NodeRemovalReply]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "../message"
2
+
3
+ class Evinrude
4
+ class Message
5
+ class ReadReply < Message
6
+ attr_reader :success, :leader_info
7
+
8
+ def initialize(success:, leader_info: nil)
9
+ @success, @leader_info = success, leader_info
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,18 @@
1
+ require_relative "../message"
2
+ require_relative "./read_reply"
3
+
4
+ class Evinrude
5
+ class Message
6
+ class ReadRequest < Message
7
+ attr_reader :commit_index
8
+
9
+ def initialize(commit_index:)
10
+ @commit_index = commit_index
11
+ end
12
+
13
+ def expected_reply_types
14
+ [ReadReply]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "../message"
2
+
3
+ class Evinrude
4
+ class Message
5
+ class VoteReply < Message
6
+ attr_reader :term, :vote_granted
7
+
8
+ def initialize(term:, vote_granted:)
9
+ @term, @vote_granted = term, vote_granted
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,18 @@
1
+ require_relative "../message"
2
+ require_relative "./vote_reply"
3
+
4
+ class Evinrude
5
+ class Message
6
+ class VoteRequest < Message
7
+ attr_reader :term, :candidate_info, :last_log_index, :last_log_term
8
+
9
+ def initialize(term:, candidate_info:, last_log_index:, last_log_term:)
10
+ @term, @candidate_info, @last_log_index, @last_log_term = term, candidate_info, last_log_index, last_log_term
11
+ end
12
+
13
+ def expected_reply_types
14
+ [VoteReply]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ require_relative "./message/append_entries_reply"
2
+ require_relative "./message/append_entries_request"
3
+ require_relative "./message/command_reply"
4
+ require_relative "./message/command_request"
5
+ require_relative "./message/install_snapshot_reply"
6
+ require_relative "./message/install_snapshot_request"
7
+ require_relative "./message/join_reply"
8
+ require_relative "./message/join_request"
9
+ require_relative "./message/node_removal_reply"
10
+ require_relative "./message/node_removal_request"
11
+ require_relative "./message/read_reply"
12
+ require_relative "./message/read_request"
13
+ require_relative "./message/vote_reply"
14
+ require_relative "./message/vote_request"
@@ -0,0 +1,50 @@
1
+ require "prometheus/client"
2
+ require "frankenstein"
3
+ require "frankenstein/remove_time_series"
4
+
5
+ class Evinrude
6
+ class Metrics
7
+ attr_reader :command_execution, :commit_index, :info, :joint_configuration,
8
+ :log_entries_persisted, :log_file_size, :log_loaded_from_disk,
9
+ :match_index, :messages_received, :next_index, :node_count,
10
+ :read_state, :remove_node, :replication_majority, :rpc,
11
+ :rpc_exception, :snapshot_file_size, :start_time, :state, :term
12
+
13
+ def initialize(registry)
14
+ @registry = registry
15
+
16
+ @command_execution = Frankenstein::Request.new(:evinrude_command, description: "state machine command", registry: @registry)
17
+ @commit_index = get_or_create(:gauge, :evinrude_commit_index, docstring: "The index of the last log entry committed to the state machine")
18
+ @info = get_or_create(:gauge, :evinrude_node_info, docstring: "Basic information about this Evinrude node in labels", labels: %i{node_name listen_address listen_port advertise_address advertise_port})
19
+ @joint_configuration = get_or_create(:gauge, :evinrude_joint_configuration_action, docstring: "Whether or not this node is currently using a 'joint' configuration for consensus")
20
+ @log_entries_persisted = get_or_create(:counter, :evinrude_log_entries_persisted_total, docstring: "How many log entries have been persisted to disk")
21
+ @log_file_size = get_or_create(:gauge, :evinrude_log_file_size_bytes, docstring: "The current size of the append-only log file")
22
+ @log_loaded_from_disk = get_or_create(:gauge, :evinrude_log_loaded_from_disk, docstring: "Whether (1) or not (0) this Evinrude node was initialized from data on disk")
23
+ @match_index = get_or_create(:gauge, :evinrude_follower_match_index, docstring: "The last log index known to be replicated to this follower", labels: %i{peer node_name})
24
+ @messages_received = get_or_create(:counter, :evinrude_messages_received_total, docstring: "How many unsolicited (RPC) messages have been received", labels: %i{type})
25
+ @next_index = get_or_create(:gauge, :evinrude_follower_next_index, docstring: "The index of the next log entry to be sent to a follower node", labels: %i{peer node_name})
26
+ @node_count = get_or_create(:gauge, :evinrude_node_count, docstring: "How many distinct nodes, active or otherwise, are currently in the cluster configuration")
27
+ @read_state = Frankenstein::Request.new(:evinrude_read_state, description: "read state check", registry: @registry)
28
+ @remove_node = Frankenstein::Request.new(:evinrude_remove_node, description: "remove node request", registry: @registry)
29
+ @replication_majority = get_or_create(:gauge, :evinrude_replication_majority_index, docstring: "The last log index that has been replicated to a majority of nodes")
30
+ @rpc = Frankenstein::Request.new(:evinrude_rpc, description: "remote procedure call", registry: @registry, labels: %i{target}, duration_labels: %i{target result})
31
+ @rpc_exception = get_or_create(:counter, :evinrude_rpc_to_leader_exceptions_total, docstring: "How many exceptions have been raised whilst trying to do RPCs to the cluster leader", labels: %i{node_name target class})
32
+ @snapshot_file_size = get_or_create(:gauge, :evinrude_snapshot_file_size_bytes, docstring: "The current size of the snapshot data file")
33
+ @start_time = get_or_create(:gauge, :evinrude_node_start_time, docstring: "The number of seconds since the Unix epoch at which this Evinrude node commenced operation")
34
+ @state = get_or_create(:gauge, :evinrude_node_state, docstring: "The current state of the node; 0=init, 1=candidate, 2=follower, 3=leader")
35
+ @term = get_or_create(:gauge, :evinrude_current_term, docstring: "The current Raft election 'term' that this node is operating in")
36
+ end
37
+
38
+ def clear_peer_metrics
39
+ [@match_index, @next_index, @replication_majority].each do |metric|
40
+ metric.values.keys.each { |ls| metric.remove(ls) }
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def get_or_create(type, name, docstring:, labels: [])
47
+ @registry.get(name) || @registry.__send__(type, name, docstring: docstring, labels: labels)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,69 @@
1
+ require "async/io"
2
+ require "socket"
3
+
4
+ require_relative "./message/join_request"
5
+ require_relative "./network/connection"
6
+
7
+ class Evinrude
8
+ class Network
9
+ class ConnectionTimeoutError < Error; end
10
+
11
+ include Evinrude::LoggingHelpers
12
+
13
+ def initialize(keys:, logger:, metrics:, listen:, advertise:)
14
+ @keys, @logger, @metrics, @advertise, @listen = keys, logger, metrics, advertise, listen
15
+
16
+ @endpoint = Async::IO::Endpoint.tcp(@listen[:address], @listen[:port])
17
+ end
18
+
19
+ def start
20
+ @socket = @endpoint.bind.first
21
+ @socket.listen(Socket::SOMAXCONN)
22
+ self
23
+ end
24
+
25
+ def advertised_address
26
+ @advertise[:address] ||
27
+ (
28
+ Socket.ip_address_list.select { |a| a.ipv6? && !a.ipv6_loopback? && !a.ipv6_linklocal? }.first ||
29
+ Socket.ip_address_list.select { |a| a.ipv4? && !a.ipv4_loopback? }.first
30
+ ).ip_address
31
+ end
32
+
33
+ def advertised_port
34
+ @advertise[:port] ||
35
+ @socket&.instance_variable_get(:@io)&.local_address&.ip_port
36
+ end
37
+
38
+ def listen_address
39
+ @listen[:address]
40
+ end
41
+
42
+ def listen_port
43
+ @listen[:port] == 0 ? advertised_port : @listen[:port]
44
+ end
45
+
46
+ def each_message(&blk)
47
+ unless @socket
48
+ bind
49
+ end
50
+
51
+ @socket.listen(Socket::SOMAXCONN)
52
+
53
+ @socket.accept_each do |sock|
54
+ conn = Network::Connection.new(socket: sock, keys: @keys, logger: logger, metrics: @metrics)
55
+
56
+ conn.each_message do |msg|
57
+ blk.call(msg, conn)
58
+ end
59
+ end
60
+ end
61
+
62
+ def connect(address:, port:)
63
+ Connection.connect(address: address, port: port, keys: @keys, logger: logger, metrics: @metrics).tap do |conn|
64
+ logger.debug(logloc) { "New connection #{conn} to #{address}:#{port}" }
65
+ yield conn if block_given?
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,144 @@
1
+ require "async/semaphore"
2
+ require "digest/sha2"
3
+ require "yaml"
4
+
5
+ require_relative "./protocol"
6
+
7
+ class Evinrude
8
+ class Network
9
+ class Connection
10
+ include Evinrude::LoggingHelpers
11
+ include Evinrude::Network::Protocol
12
+
13
+ class ConnectionError < Error; end
14
+
15
+ attr_reader :peer_address, :peer_port
16
+
17
+ def self.connect(address:, port:, keys:, logger:, metrics:)
18
+ backoff = Evinrude::Backoff.new
19
+
20
+ begin
21
+ sock = Async::IO::Endpoint.tcp(address, port).connect
22
+ rescue Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ETIMEDOUT => ex
23
+ logger.info("Evinrude::Network::Connection.connect") { "Could not connect to #{address}:#{port}: #{ex.class}" }
24
+ raise ConnectionError,
25
+ "#{ex.class}"
26
+ end
27
+
28
+ new(socket: sock, logger: logger, metrics: metrics, keys: keys)
29
+ end
30
+
31
+ def initialize(socket:, logger:, metrics:, keys:)
32
+ @socket, @logger, @metrics = socket, logger, metrics
33
+
34
+ @keys = keys.map { |k| Digest::SHA256.digest(k) }
35
+
36
+ @sem = Async::Semaphore.new
37
+
38
+ @peer_address = @socket.remote_address.ip_address
39
+ @peer_port = @socket.remote_address.ip_port
40
+ end
41
+
42
+ def peer_info
43
+ "#{peer_address}:#{peer_port}"
44
+ end
45
+
46
+ def rpc(msg)
47
+ @metrics.rpc.measure(target: peer_info) do |labels|
48
+ begin
49
+ @sem.acquire do
50
+ logger.debug(logloc) { "Sending RPC request #{msg.inspect} to #{peer_info}" }
51
+ begin
52
+ @socket.write(frame(box(msg.to_yaml)))
53
+ rescue Errno::EPIPE, IOError, Errno::ECONNRESET => ex
54
+ logger.debug(logloc) { "Failed to send RPC request to #{peer_info}: #{ex.message} (#{ex.class})" }
55
+ labels[:result] = ex.class.to_s
56
+ return nil
57
+ end
58
+
59
+ logger.debug(logloc) { "Request sent; now we wait" }
60
+
61
+ begin
62
+ read_message
63
+ rescue Protocol::VersionError, Errno::ECONNRESET, Errno::EPIPE, IOError => ex
64
+ logger.debug(logloc) { "I/O exception #{ex.class} while reading RPC reply" }
65
+ labels[:result] = ex.class.to_s
66
+ @socket.close
67
+ nil
68
+ end
69
+ end.tap { labels[:result] = "success" }
70
+ rescue Async::Wrapper::Cancelled
71
+ labels[:result] = "cancelled"
72
+ nil
73
+ end
74
+ end
75
+ end
76
+
77
+ def each_message
78
+ begin
79
+ loop do
80
+ @sem.acquire do
81
+ yield read_message
82
+ end
83
+ end
84
+ rescue Async::Wrapper::Cancelled
85
+ # This is fine
86
+ nil
87
+ rescue Evinrude::Error, SystemCallError, IOError => ex
88
+ # This is... not so fine, but there's not much we can do about it
89
+ log_exception(ex) { "Reading message" }
90
+ @socket.close
91
+ nil
92
+ end
93
+ end
94
+
95
+ def send_reply(msg)
96
+ @socket.write(frame(box(msg.to_yaml)))
97
+ end
98
+
99
+ def close
100
+ @socket.close
101
+ end
102
+
103
+ def inspect
104
+ "#<#{self.class}:0x#{object_id.to_s(16)} " +
105
+ instance_variables.map do |iv|
106
+ next nil if %i{@logger @metrics @socket}.include?(iv)
107
+ "#{iv}=#{instance_variable_get(iv).inspect}"
108
+ end.compact.join(" ")
109
+ end
110
+
111
+ private
112
+
113
+ def read_message
114
+ v = @socket.read(1)
115
+
116
+ if v.nil?
117
+ logger.debug(logloc) { "Connection to #{peer_info} closed" }
118
+ raise Async::Wrapper::Cancelled
119
+ end
120
+
121
+ unless v == "\x00"
122
+ raise Protocol::VersionError, "Expected 0, got #{v.inspect}"
123
+ end
124
+
125
+ lenlen = @socket.read(1).ord
126
+
127
+ if (lenlen & 0x80) == 0
128
+ len = lenlen
129
+ else
130
+ lenlen &= 0x7f
131
+ len = @socket.read(lenlen).split(//).inject(0) { |a, c| a * 256 + c.ord }
132
+ end
133
+
134
+ if len > max_message_size
135
+ raise MessageTooBigError
136
+ end
137
+
138
+ box = @socket.read(len)
139
+
140
+ YAML.safe_load(unbox(box), permitted_classes: Message.classes + LogEntry.classes + [NodeInfo, ClusterConfiguration, Symbol], aliases: true)
141
+ end
142
+ end
143
+ end
144
+ end