raft 0.0.2

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 (3) hide show
  1. data/lib/raft.rb +355 -0
  2. data/lib/raft/goliath.rb +156 -0
  3. metadata +63 -0
@@ -0,0 +1,355 @@
1
+ module Raft
2
+ Config = Struct.new(:rpc_provider, :async_provider, :election_timeout, :update_interval, :heartbeat_interval)
3
+
4
+ class Cluster
5
+ attr_accessor :node_ids
6
+
7
+ def quorum
8
+ @node_ids.size / 2 + 1 # integer division rounds down
9
+ end
10
+ end
11
+
12
+ PersistentState = Struct.new(:current_term, :voted_for, :log)
13
+
14
+ TemporaryState = Struct.new(:commit_index, :leader_id)
15
+
16
+ class LeadershipState
17
+ def followers
18
+ @followers ||= {}
19
+ end
20
+
21
+ attr_reader :update_timer
22
+
23
+ def initialize(update_interval)
24
+ @update_timer = Timer.new(update_interval)
25
+ end
26
+ end
27
+
28
+ FollowerState = Struct.new(:next_index, :succeeded)
29
+
30
+ LogEntry = Struct.new(:term, :index, :command)
31
+
32
+ RequestVoteRequest = Struct.new(:term, :candidate_id, :last_log_index, :last_log_term)
33
+
34
+ RequestVoteResponse = Struct.new(:term, :vote_granted)
35
+
36
+ AppendEntriesRequest = Struct.new(:term, :leader_id, :prev_log_index, :prev_log_term, :entries, :commit_index)
37
+
38
+ AppendEntriesResponse = Struct.new(:term, :success)
39
+
40
+ CommandRequest = Struct.new(:command)
41
+
42
+ CommandResponse = Struct.new(:success)
43
+
44
+ class RpcProvider
45
+ def request_votes(request, cluster)
46
+ raise "Your RpcProvider subclass must implement #request_votes"
47
+ end
48
+
49
+ def append_entries(request, cluster)
50
+ raise "Your RpcProvider subclass must implement #append_entries"
51
+ end
52
+
53
+ def append_entries_to_follower(request, node_id)
54
+ raise "Your RpcProvider subclass must implement #append_entries_to_follower"
55
+ end
56
+
57
+ def command(request, node_id)
58
+ raise "Your RpcProvider subclass must implement #command"
59
+ end
60
+ end
61
+
62
+ class AsyncProvider
63
+ def await
64
+ raise "Your AsyncProvider subclass must implement #await"
65
+ end
66
+ end
67
+
68
+ class Timer
69
+ def initialize(interval)
70
+ @timeout = Time.now + timeout
71
+ reset!
72
+ end
73
+
74
+ def reset!
75
+ @start = Time.now
76
+ end
77
+
78
+ def timeout
79
+ @start + @interval
80
+ end
81
+
82
+ def timed_out?
83
+ Time.now > timeout
84
+ end
85
+ end
86
+
87
+ class Node
88
+ attr_reader :id
89
+ attr_reader :role
90
+ attr_reader :config
91
+ attr_reader :cluster
92
+ attr_reader :persistent_state
93
+ attr_reader :temporary_state
94
+ attr_reader :election_timer
95
+
96
+ FOLLOWER_ROLE = 0
97
+ CANDIDATE_ROLE = 1
98
+ LEADER_ROLE = 2
99
+
100
+ def initialize(id, config, cluster)
101
+ @id = id
102
+ @role = FOLLOWER_ROLE
103
+ @config = config
104
+ @cluster = cluster
105
+ @persistent_state = PersistentState.new(0, nil, [])
106
+ @temporary_state = TemporaryState.new(nil, nil)
107
+ @election_timer = Timer.new(config.election_timeout)
108
+ end
109
+
110
+ def update
111
+ case @role
112
+ when FOLLOWER_ROLE
113
+ follower_update
114
+ when CANDIDATE_ROLE
115
+ candidate_update
116
+ when LEADER_ROLE
117
+ leader_update
118
+ end
119
+ end
120
+
121
+ def follower_update
122
+ if @election_timer.timed_out?
123
+ @role = CANDIDATE_ROLE
124
+ candidate_update
125
+ end
126
+ end
127
+ protected :follower_update
128
+
129
+ def candidate_update
130
+ if @election_timer.timed_out?
131
+ @persistent_state.current_term += 1
132
+ @persistent_state.voted_for = @id
133
+ reset_election_timeout
134
+ request = RequestVoteRequest.new(@persistent_state.current_term, @id, @persistent_state.log.last.index, @persistent_state.log.last.term)
135
+ votes_for = 1 # candidate always votes for self
136
+ votes_against = 0
137
+ quorum = @cluster.quorum
138
+ elected = @config.rpc_provider.request_votes(request, @cluster) do |_, response|
139
+ if response.term > @persistent_state.current_term
140
+ @role = FOLLOWER_ROLE
141
+ return false
142
+ elsif response.vote_granted
143
+ votes_for += 1
144
+ return false if votes_for >= quorum
145
+ else
146
+ votes_against += 1
147
+ return false if votes_against >= quorum
148
+ end
149
+ nil # no majority result yet
150
+ end
151
+ if elected
152
+ @role = LEADER_ROLE
153
+ establish_leadership
154
+ end
155
+ end
156
+ end
157
+ protected :candidate_update
158
+
159
+ def leader_update
160
+ if @leadership_state.update_timer.timed_out?
161
+ @leadership_state.update_timer.reset!
162
+ send_heartbeats
163
+ end
164
+ @temporary_state.commit_index = @leadership_state.followers.values.
165
+ select {|follower_state| follower_state.succeeded}.
166
+ map {|follower_state| follower_state.next_index}.
167
+ sort[@cluster.quorum - 1]
168
+ end
169
+ protected :leader_update
170
+
171
+ def establish_leadership
172
+ @leadership_state = LeadershipState.new(@config.update_interval)
173
+ @cluster.node_ids.each do |node_id|
174
+ follower_state = (@leadership_state.followers[node_id] ||= FollowerState.new)
175
+ follower_state.next_index = @persistent_state.log.size + 1
176
+ follower_state.succeeded = false
177
+ end
178
+ send_heartbeats
179
+ end
180
+ protected :establish_leadership
181
+
182
+ def send_heartbeats
183
+ request = AppendEntriesRequest.new(
184
+ @persistent_state.current_term,
185
+ @id,
186
+ @persistent_state.log.size - 1,
187
+ @persistent_state.log.last.term,
188
+ [],
189
+ @temporary_state.commit_index)
190
+
191
+ @config.rpc_provider.append_entries(request, @cluster) do |node_id, response|
192
+ append_entries_to_follower(node_id, request, response)
193
+ end
194
+ end
195
+ protected :send_heartbeats
196
+
197
+ def append_entries_to_follower(node_id, request, response)
198
+ if response.success
199
+ @leadership_state.followers[node_id].next_index = request.log.last.index
200
+ @leadership_state.followers[node_id].succeeded = true
201
+ elsif response.term <= @persistent_state.current_term
202
+ @config.rpc_provider.append_entries_to_follower(request, node_id) do |node_id, response|
203
+ prev_log_index = request.prev_log_index > 1 ? request.prev_log_index - 1 : nil
204
+ prev_log_term = nil
205
+ entries = @persistent_state.log
206
+ unless prev_log_index.nil?
207
+ prev_log_term = @persistent_state.log[prev_log_index].term
208
+ entries = @persistent_state.log.slice((prev_log_index + 1)..-1)
209
+ end
210
+ next_request = AppendEntriesRequest.new(
211
+ @persistent_state.current_term,
212
+ @id,
213
+ prev_log_index,
214
+ prev_log_term,
215
+ entries,
216
+ @temporary_state.commit_index)
217
+ @config.rpc_provider.append_entries_to_follower(next_request, @temporary_state.leader_id) do |node_id, response|
218
+ append_entries_to_follower(node_id, next_request, response)
219
+ end
220
+ end
221
+ end
222
+ end
223
+ protected :append_entries_to_follower
224
+
225
+ def handle_request_vote(request)
226
+ response = RequestVoteResponse.new
227
+ response.term = @persistent_state.current_term
228
+ response.vote_granted = false
229
+
230
+ return response if request.term < @persistent_state.current_term
231
+
232
+ @temporary_state.leader_id = nil if request.term > @persistent_state.current_term
233
+
234
+ step_down_if_new_term(request.term)
235
+
236
+ if FOLLOWER_ROLE == @role
237
+ if @persistent_state.voted_for == request.candidate_id
238
+ response.vote_granted = success
239
+ elsif @persistent_state.voted_for.nil?
240
+ if request.last_log_term == @persistent_state.log.last.term &&
241
+ request.last_log_index < @persistent_state.log.last.index
242
+ # candidate's log is incomplete compared to this node
243
+ elsif request.last_log_term < @persistent_state.log.last.term
244
+ # candidate's log is incomplete compared to this node
245
+ @persistent_state.voted_for = request.candidate_id
246
+ else
247
+ @persistent_state.voted_for = request.candidate_id
248
+ response.vote_granted = true
249
+ end
250
+ end
251
+ reset_election_timeout if response.vote_granted
252
+ end
253
+
254
+ response
255
+ end
256
+
257
+ def handle_append_entries(request)
258
+ response = AppendEntriesResponse.new
259
+ response.term = @persistent_state.current_term
260
+ response.success = false
261
+
262
+ return response if request.term < @persistent_state.current_term
263
+
264
+ step_down_if_new_term(request.term)
265
+
266
+ reset_election_timeout
267
+
268
+ @temporary_state.leader_id = request.leader_id
269
+
270
+ abs_log_index = abs_log_index_for(request.prev_log_index, request.prev_log_term)
271
+ return response if abs_log_index.nil? && !request.prev_log_index.nil? && !request.prev_log_term.nil?
272
+
273
+ raise "Cannot truncate committed logs" if abs_log_index < @temporary_state.commit_index
274
+
275
+ truncate_and_update_log(abs_log_index, request.entries)
276
+
277
+ return response unless update_commit_index(request.commit_index)
278
+
279
+ response.success = true
280
+ response
281
+ end
282
+
283
+ def handle_command(request)
284
+ response = CommandResponse.new(false)
285
+ case @role
286
+ when FOLLOWER_ROLE
287
+ response = @config.rpc_provider.command(request, @temporary_state.leader_id)
288
+ when CANDIDATE_ROLE
289
+ await_leader
290
+ response = handle_command(request)
291
+ when LEADER_ROLE
292
+ log_entry = LogEntry.new(@persistent_state.current_term, @persistent_state.log.last.index + 1, request.command)
293
+ @persistent_state.log << log_entry
294
+ await_consensus(log_entry)
295
+ end
296
+ response
297
+ end
298
+
299
+ def await_consensus(log_entry)
300
+ @config.async_provider.await do
301
+ persisted_log_entry = @persistent_state.log[log_entry.index - 1]
302
+ @temporary_state.commit_index >= log_entry.index &&
303
+ persisted_log_entry.term == log_entry.term &&
304
+ persisted_log_entry.command == log_entry.command
305
+ end
306
+ end
307
+ protected :await_consensus
308
+
309
+ def await_leader
310
+ @config.async_provider.await do
311
+ @role != CANDIDATE_ROLE && !@temporary_state.leader_id.nil?
312
+ end
313
+ end
314
+ protected :await_leader
315
+
316
+ def step_down_if_new_term(request_term)
317
+ if request_term > @persistent_state.current_term
318
+ @persistent_state.current_term = request_term
319
+ @persistent_state.voted_for = nil
320
+ @role = FOLLOWER_ROLE
321
+ end
322
+ end
323
+ protected :step_down_if_new_term
324
+
325
+ def reset_election_timeout
326
+ @election_timer.reset!
327
+ end
328
+ protected :reset_election_timeout
329
+
330
+ def abs_log_index_for(prev_log_index, prev_log_term)
331
+ @persistent_state.log.rindex {|log_entry| log_entry.index == prev_log_index && log_entry.term == prev_log_term}
332
+ end
333
+ protected :abs_log_index_for
334
+
335
+ def truncate_and_update_log(abs_log_index, entries)
336
+ log = @persistent_state.log
337
+ if abs_log_index.nil?
338
+ log = []
339
+ elsif log.length == abs_log_index + 1
340
+ # no truncation required, past log is the same
341
+ else
342
+ log = log.slice(0..abs_log_index)
343
+ end
344
+ log = log.concat(entries) unless entries.empty?
345
+ @persistent_state.log = log
346
+ end
347
+ protected :truncate_and_update_log
348
+
349
+ def update_commit_index(commit_index)
350
+ return false if @temporary_state.commit_index && @temporary_state.commit_index > commit_index
351
+ @temporary_state.commit_index = commit_index
352
+ end
353
+ protected :update_commit_index
354
+ end
355
+ end
@@ -0,0 +1,156 @@
1
+ require 'lib/raft'
2
+
3
+ require 'goliath'
4
+
5
+ module Raft
6
+ class Goliath
7
+ class HttpJsonRpcResponder < Goliath::API
8
+ use Goliath::Rack::Render, 'json'
9
+ use Goliath::Rack::Validation::RequestMethod, %w(POST)
10
+ use Goliath::Rack::Params
11
+
12
+ def initialize(node)
13
+ @node = node
14
+ end
15
+
16
+ def response(env)
17
+ case env['REQUEST_PATH']
18
+ when '/request_vote'
19
+ handle_errors {request_vote_response(env['params'])}
20
+ when '/append_entries'
21
+ handle_errors {append_entries_response(env['params'])}
22
+ when '/command'
23
+ handle_errors {command_response(env['params'])}
24
+ else
25
+ error_response(404, 'not found')
26
+ end
27
+ end
28
+
29
+ def request_vote_response(params)
30
+ request = Raft::RequestVoteRequest.new(
31
+ params['term'],
32
+ params['candidate_id'],
33
+ params['last_log_index'],
34
+ params['last_log_term'])
35
+ response = @node.handle_request_vote(request)
36
+ [200, {}, {'term' => response.term, 'vote_granted' => response.vote_granted}]
37
+ end
38
+
39
+ def append_entries_response(params)
40
+ Raft::AppendEntriesRequest.new(
41
+ params['term'],
42
+ params['leader_id'],
43
+ params['prev_log_index'],
44
+ params['prev_log_term'],
45
+ params['entries'],
46
+ params['commit_index'])
47
+ response = @node.handle_append_entries(request)
48
+ [200, {}, {'term' => response.term, 'success' => response.success}]
49
+ end
50
+
51
+ def command_response(params)
52
+ Raft::CommandRequest.new(params['command'])
53
+ response = @node.handle_command(request)
54
+ [response.success ? 200 : 409, {}, {'success' => response.success}]
55
+ end
56
+
57
+ def handle_errors
58
+ yield
59
+ rescue StandardError => se
60
+ error_response(422, se.message)
61
+ rescue Exception => e
62
+ error_response(500, e.message)
63
+ end
64
+
65
+ def error_response(code, message)
66
+ [code, {}, {'error' => message}]
67
+ end
68
+ end
69
+
70
+ #TODO: implement HttpJsonRpcProvider!
71
+ class HttpJsonRpcProvider < Raft::RpcProvider
72
+ attr_reader :url_generator
73
+
74
+ def initialize(url_generator)
75
+ @url_generator = url_generator
76
+ end
77
+
78
+ def request_votes(request, cluster)
79
+ #multi = EventMachine::Synchrony::Multi.new
80
+ #cluster.node_ids.each do |node_id|
81
+ # multi.add node_id, EventMachine::HttpRequest.new(url_generator.call(node_id)).apost
82
+ #end
83
+ end
84
+
85
+ def append_entries(request, cluster)
86
+ raise "Your RpcProvider subclass must implement #append_entries"
87
+ end
88
+
89
+ def append_entries_to_follower(request, node_id)
90
+ raise "Your RpcProvider subclass must implement #append_entries_to_follower"
91
+ end
92
+
93
+ def command(request, node_id)
94
+ raise "Your RpcProvider subclass must implement #command"
95
+ end
96
+ end
97
+
98
+ class EventMachineAsyncProvider < Raft::AsyncProvider
99
+ def await
100
+ until yield
101
+ f = Fiber.current
102
+ EventMachine::add_timer(0.1) do
103
+ f.resume
104
+ end
105
+ Fiber.yield
106
+ end
107
+ end
108
+ end
109
+
110
+ def self.rpc_provider
111
+ HttpJsonRpcProvider.new
112
+ end
113
+
114
+ def self.async_provider
115
+ EventMachineAsyncProvider.new
116
+ end
117
+
118
+ def initialize(node)
119
+
120
+ end
121
+
122
+ attr_reader :update_fiber
123
+ attr_reader :running
124
+
125
+ def start
126
+ @runner = Goliath::Runner.new(ARGV, nil)
127
+ @runner.api = HttpJsonRpcResponder.new(node)
128
+ @runner.app = Goliath::Rack::Builder.build(HttpJsonRpcResponder, runner.api)
129
+ @runner.run
130
+
131
+ @running = true
132
+
133
+ runner = self
134
+ @update_fiber = Fiber.new do
135
+ while runner.running?
136
+ EventMachine.add_timer node.config.update_interval, Proc.new do
137
+ runner.update_fiber.resume if runner.running?
138
+ end
139
+ @node.update
140
+ Fiber.yield
141
+ end
142
+ end
143
+ end
144
+
145
+ def stop
146
+ @running = false
147
+ f = Fiber.current
148
+ while @update_fiber.alive?
149
+ EventMachine.add_timer 0.1, proc { f.resume }
150
+ Fiber.yield
151
+ end
152
+ end
153
+ end
154
+ end
155
+
156
+
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: raft
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Harry Wilkinson
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-06-02 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: goliath
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 1.0.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: 1.0.0
30
+ description: A simple Raft distributed consensus implementation
31
+ email: hwilkinson@mdsol.com
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - lib/raft.rb
37
+ - lib/raft/goliath.rb
38
+ homepage: http://github.com/harryw/raft
39
+ licenses: []
40
+ post_install_message:
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ none: false
46
+ requirements:
47
+ - - ! '>='
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ none: false
52
+ requirements:
53
+ - - ! '>='
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ requirements: []
57
+ rubyforge_project:
58
+ rubygems_version: 1.8.25
59
+ signing_key:
60
+ specification_version: 3
61
+ summary: A simple Raft distributed consensus implementation
62
+ test_files: []
63
+ has_rdoc: