raft 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
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: