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.
- 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:
|