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,19 @@
1
+ class Evinrude
2
+ class Backoff
3
+ def initialize(slot_time: 0.5, max_slots: 30)
4
+ @slot_time, @max_slots = slot_time, max_slots
5
+
6
+ @fail_count = 0
7
+ end
8
+
9
+ def wait_time
10
+ @fail_count += 1
11
+
12
+ [2 ** @fail_count, @max_slots].min * rand * @slot_time
13
+ end
14
+
15
+ def wait
16
+ sleep wait_time
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,162 @@
1
+ class Evinrude
2
+ class ClusterConfiguration
3
+ class TransitionInProgressError < StandardError; end
4
+
5
+ include Evinrude::LoggingHelpers
6
+
7
+ def initialize(logger:, metrics:)
8
+ @logger, @metrics = logger, metrics
9
+ @old = []
10
+ @new = []
11
+ @transition_in_progress = false
12
+ @metrics.joint_configuration.set(0)
13
+ @m = Mutex.new
14
+ end
15
+
16
+ def transitioning?
17
+ @transition_in_progress
18
+ end
19
+
20
+ def nodes
21
+ locked = false
22
+ unless @m.owned?
23
+ @m.lock
24
+ locked = true
25
+ end
26
+
27
+ (@old + @new).uniq
28
+ ensure
29
+ @m.unlock if locked
30
+ end
31
+
32
+ def add_node(node_info)
33
+ @m.synchronize do
34
+ if @transition_in_progress
35
+ raise TransitionInProgressError,
36
+ "Cannot add a node whilst a config transition is in progress (@old=#{@old.inspect}, @new=#{@new.inspect})"
37
+ end
38
+
39
+ logger.debug(logloc) { "Commencing addition of #{node_info.inspect} to cluster config" }
40
+
41
+ # Adding a new node with the same name but, presumably, a different
42
+ # address and/or port triggers a config change in which the old
43
+ # address/port is removed and the new address/port is added.
44
+ existing_node = @old.find { |n| n.name == node_info.name }
45
+
46
+ @new = @old + [node_info] - [existing_node].compact
47
+ if @metrics
48
+ @metrics.node_count&.set(nodes.length)
49
+ @metrics.joint_configuration.set(1)
50
+ end
51
+ @transition_in_progress = true
52
+ end
53
+ end
54
+
55
+ def remove_node(node_info, force: false)
56
+ @m.synchronize do
57
+ if @transition_in_progress && !force
58
+ raise TransitionInProgressError,
59
+ "Cannot remove a node whilst a config transition is in progress"
60
+ end
61
+
62
+ logger.debug(logloc) { "Commencing #{force ? "forced " : ""}removal of #{node_info.inspect} from cluster config" }
63
+
64
+ @new = @old - [node_info]
65
+ if @metrics
66
+ @metrics.node_count&.set(nodes.length)
67
+ @metrics.joint_configuration.set(1)
68
+ end
69
+ @transition_in_progress = true
70
+
71
+ if force
72
+ joint_configuration_replicated
73
+ end
74
+ end
75
+ end
76
+
77
+ def joint_configuration_replicated
78
+ unlock = false
79
+
80
+ unless @m.owned?
81
+ @m.lock
82
+ unlock = true
83
+ end
84
+
85
+ logger.debug(logloc) { "Joint configuration has been replicated" }
86
+ @old = @new
87
+ @new = []
88
+ @transition_in_progress = false
89
+ @metrics&.joint_configuration&.set(0)
90
+ ensure
91
+ @m.unlock if unlock
92
+ end
93
+
94
+ def quorum_met?(present_nodes)
95
+ @m.synchronize do
96
+ group_quorum?(@old, present_nodes) && group_quorum?(@new, present_nodes)
97
+ end
98
+ end
99
+
100
+ def [](id)
101
+ nodes.find { |n| n.id == id }
102
+ end
103
+
104
+ def encode_with(coder)
105
+ @m.synchronize do
106
+ instance_variables.each do |iv|
107
+ next if %i{@logger @metrics @m}.include?(iv)
108
+ coder[iv.to_s.sub(/^@/, '')] = instance_variable_get(iv)
109
+ end
110
+ end
111
+ end
112
+
113
+ def init_with(coder)
114
+ @m = Mutex.new
115
+
116
+ coder.map.each do |k, v|
117
+ instance_variable_set(:"@#{k}", v)
118
+ end
119
+ end
120
+
121
+ def inspect
122
+ @m.synchronize do
123
+ "#<#{self.class}:0x#{object_id.to_s(16)} " +
124
+ instance_variables.map do |iv|
125
+ next nil if iv == :@logger || iv == :@metrics
126
+ "#{iv}=#{instance_variable_get(iv).inspect}"
127
+ end.compact.join(" ")
128
+ end
129
+ end
130
+
131
+ def logger=(l)
132
+ if @logger
133
+ raise ArgumentError, "Logger cannot be changed once set"
134
+ end
135
+
136
+ @logger = l
137
+ end
138
+
139
+ def metrics=(m)
140
+ if @metrics
141
+ raise ArgumentError, "Metrics cannot be changed once set"
142
+ end
143
+
144
+ @metrics = m
145
+ end
146
+
147
+ private
148
+
149
+ attr_reader :old, :new
150
+
151
+ def group_quorum?(group, present_nodes)
152
+ if group.length < 2
153
+ # Quorum is automatically met if the group isn't in use (empty) or
154
+ # if the group is just one (which can only be "us")
155
+ true
156
+ else
157
+ logger.debug(logloc) { "Checking if #{present_nodes.inspect} meets quorum requirement for #{group.inspect}" }
158
+ (group.select { |m| present_nodes.include?(m) }.length.to_f / group.length.to_f > 0.5).tap { |v| logger.debug(logloc) { v ? "Quorum met" : "Quorum failed" } }
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,19 @@
1
+ class Evinrude
2
+ class ConfigChangeQueueEntry
3
+ def initialize(msg, conn = nil)
4
+ @msg, @conn = msg, conn
5
+ end
6
+
7
+ def node_info
8
+ @msg.node_info
9
+ end
10
+
11
+ def send_successful_reply
12
+ @conn.send_reply(reply_class.new(success: true))
13
+ end
14
+
15
+ def send_redirect_reply(leader_info)
16
+ @conn.send_reply(reply_class.new(success: false, leader_info: leader_info))
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,13 @@
1
+ require_relative "../config_change_queue_entry"
2
+
3
+ class Evinrude
4
+ class ConfigChangeQueueEntry
5
+ class AddNode < ConfigChangeQueueEntry
6
+ private
7
+
8
+ def reply_class
9
+ Message::JoinReply
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,14 @@
1
+ require_relative "../config_change_queue_entry"
2
+
3
+ class Evinrude
4
+ class ConfigChangeQueueEntry
5
+ class RemoveNode < ConfigChangeQueueEntry
6
+
7
+ private
8
+
9
+ def reply_class
10
+ Message::NodeRemovalReply
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ class Range
2
+ def rand
3
+ first + Kernel.rand * (last - first)
4
+ end
5
+ end
@@ -0,0 +1,102 @@
1
+ require "yaml"
2
+
3
+ class Evinrude
4
+ class Log
5
+ include Evinrude::LoggingHelpers
6
+
7
+ class TruncationUnderflowError < Error; end
8
+ class SnapshottedEntryError < Error; end
9
+
10
+ attr_reader :snapshot_last_term, :snapshot_last_index
11
+
12
+ def initialize(logger:, snapshot_last_term: 0, snapshot_last_index: 0)
13
+ @logger, @snapshot_last_term, @snapshot_last_index = logger, snapshot_last_term, snapshot_last_index
14
+
15
+ @entries = []
16
+ end
17
+
18
+ def append(entry)
19
+ logger.debug(logloc) { "Appending new entry #{entry.inspect} as ##{@snapshot_last_index + @entries.length + 1}" }
20
+ @entries << entry
21
+
22
+ if @entries.length > 1000
23
+ old_len = @entries.length
24
+ snapshotted_entries = @entries[0..499]
25
+ @entries = @entries[500..]
26
+ @snapshot_last_index += 500
27
+ @snapshot_last_term = snapshotted_entries.last.term
28
+ end
29
+ end
30
+
31
+ def new_snapshot(last_term, last_index)
32
+ @snapshot_last_term = last_term
33
+ @snapshot_last_index = last_index
34
+
35
+ @entries = []
36
+ end
37
+
38
+ def has_entry?(n)
39
+ n == 0 || n <= @snapshot_last_index + @entries.length
40
+ end
41
+
42
+ def snapshotted_entry?(n)
43
+ n > 0 && n <= @snapshot_last_index
44
+ end
45
+
46
+ def last_index
47
+ @entries.length + @snapshot_last_index
48
+ end
49
+
50
+ def last_entry_term
51
+ if @entries.empty?
52
+ @snapshot_last_term
53
+ else
54
+ @entries.last.term
55
+ end
56
+ end
57
+
58
+ def entry_term(n)
59
+ if n == @snapshot_last_index
60
+ @snapshot_last_term
61
+ else
62
+ self[n]&.term
63
+ end
64
+ end
65
+
66
+ def entries_from(n)
67
+ @entries[(n-@snapshot_last_index-1)..] || []
68
+ end
69
+
70
+ # Make the last entry kept in the log the nth.
71
+ def truncate_to(n)
72
+ if n > @snapshot_last_index
73
+ @entries = @entries[0..n-@snapshot_last_index-1]
74
+ elsif n == @snapshot_last_index
75
+ @entries = []
76
+ else
77
+ raise TruncationUnderflowError,
78
+ "Cannot truncate to log entry ##{n}; into the snapshot"
79
+ end
80
+ end
81
+
82
+ def [](n)
83
+ if n == 0
84
+ zeroth_log_entry
85
+ elsif n <= @snapshot_last_index
86
+ raise SnapshottedEntryError
87
+ else
88
+ @entries[n - @snapshot_last_index - 1]
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def zeroth_log_entry
95
+ @zeroth_log_entry ||= LogEntry::Null.new(term: 0)
96
+ end
97
+
98
+ def snapshot_log_entry
99
+ @snapshot_log_entry ||= LogEntry::Null.new(term: @snapshot_last_term)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,3 @@
1
+ require_relative "./log_entry/cluster_configuration"
2
+ require_relative "./log_entry/null"
3
+ require_relative "./log_entry/state_machine_command"
@@ -0,0 +1,13 @@
1
+ class Evinrude
2
+ class LogEntry
3
+ def self.classes
4
+ Evinrude::LogEntry.constants.map { |c| Evinrude::LogEntry.const_get(c) }.select { |c| Class === c }
5
+ end
6
+
7
+ attr_reader :term
8
+
9
+ def initialize(term:)
10
+ @term = term
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ require_relative "../log_entry"
2
+
3
+ class Evinrude
4
+ class LogEntry
5
+ class ClusterConfiguration < LogEntry
6
+ attr_reader :config
7
+
8
+ def initialize(term:, config:)
9
+ super(term: term)
10
+
11
+ @config = config
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ class Evinrude
2
+ class LogEntry
3
+ class Null < LogEntry
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,13 @@
1
+ class Evinrude
2
+ class LogEntry
3
+ class StateMachineCommand < LogEntry
4
+ attr_reader :command, :id, :node_name
5
+
6
+ def initialize(term:, command:, id:, node_name:)
7
+ super(term: term)
8
+
9
+ @command, @id, @node_name = command, id, node_name
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ class Evinrude
6
+ module LoggingHelpers
7
+ private
8
+
9
+ def logger
10
+ @logger || Logger.new("/dev/null")
11
+ end
12
+
13
+ def logloc
14
+ loc = caller_locations.first
15
+ "#{self.class}##{loc.label}"
16
+ end
17
+
18
+ def log_exception(ex, progname = nil)
19
+ progname ||= "#{self.class.to_s}##{caller_locations(2, 1).first.label}"
20
+
21
+ logger.error(progname) do
22
+ explanation = if block_given?
23
+ yield
24
+ else
25
+ nil
26
+ end
27
+
28
+ format_backtrace("#{explanation}#{explanation ? ": " : ""}#{ex.message} (#{ex.class})", ex.backtrace)
29
+ end
30
+ end
31
+
32
+ def with_backtrace(msg)
33
+ format_backtrace(msg, caller[1..])
34
+ end
35
+
36
+ def format_backtrace(msg, backtrace)
37
+ ([msg] + backtrace).join("\n ")
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,19 @@
1
+ require "yaml"
2
+
3
+ class Evinrude
4
+ class Message
5
+ class ParseError < Evinrude::Error; end
6
+
7
+ def self.parse(m)
8
+ YAML.safe_load(m, permitted_classes: Evinrude::Message.permitted_classes, aliases: true)
9
+ end
10
+
11
+ def self.permitted_classes
12
+ Evinrude::Message.classes + Evinrude::LogEntry.classes + [Evinrude::NodeInfo, Evinrude::ClusterConfiguration, Symbol]
13
+ end
14
+
15
+ def self.classes
16
+ Evinrude::Message.constants.map { |c| Evinrude::Message.const_get(c) }.select { |c| Class === c }
17
+ end
18
+ end
19
+ end