raft 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/lib/raft.rb +355 -0
- data/lib/raft/goliath.rb +156 -0
- metadata +63 -0
data/lib/raft.rb
ADDED
@@ -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
|
data/lib/raft/goliath.rb
ADDED
@@ -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:
|