einhorn 0.5.4 → 0.5.5
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/einhorn.gemspec +4 -5
- data/example/time_server +20 -2
- data/lib/einhorn/command/interface.rb +18 -6
- data/lib/einhorn/command.rb +99 -11
- data/lib/einhorn/compat.rb +36 -0
- data/lib/einhorn/version.rb +1 -1
- data/lib/einhorn/worker_pool.rb +10 -0
- data/lib/einhorn.rb +1 -0
- data/test/unit/einhorn/command/interface.rb +1 -1
- metadata +16 -48
data/einhorn.gemspec
CHANGED
@@ -14,13 +14,12 @@ Gem::Specification.new do |gem|
|
|
14
14
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
15
15
|
gem.name = 'einhorn'
|
16
16
|
gem.require_paths = ['lib']
|
17
|
+
|
17
18
|
gem.add_development_dependency 'rake'
|
18
|
-
gem.add_development_dependency '
|
19
|
-
gem.add_development_dependency '
|
19
|
+
gem.add_development_dependency 'pry'
|
20
|
+
gem.add_development_dependency 'minitest', '< 5.0'
|
21
|
+
gem.add_development_dependency 'mocha', '~> 0.13'
|
20
22
|
gem.add_development_dependency 'chalk-rake'
|
21
23
|
|
22
|
-
gem.add_development_dependency('rake')
|
23
|
-
gem.add_development_dependency('minitest', '< 5.0')
|
24
|
-
gem.add_development_dependency('mocha', '~> 0.13')
|
25
24
|
gem.version = Einhorn::VERSION
|
26
25
|
end
|
data/example/time_server
CHANGED
@@ -15,18 +15,36 @@
|
|
15
15
|
require 'rubygems'
|
16
16
|
require 'einhorn/worker'
|
17
17
|
|
18
|
+
def log(msg)
|
19
|
+
puts "=== [#{$$}] #{msg}"
|
20
|
+
end
|
21
|
+
|
18
22
|
def einhorn_main
|
19
|
-
|
23
|
+
log "Called with ENV['EINHORN_FD_0']: #{ENV['EINHORN_FD_0']}"
|
20
24
|
|
21
25
|
fd_num = Einhorn::Worker.socket!
|
22
26
|
socket = Socket.for_fd(fd_num)
|
23
27
|
|
28
|
+
sleep_before_shutdown = 0
|
29
|
+
sleep_before_ack = 0
|
30
|
+
|
24
31
|
# Came up successfully, so let's set up graceful handler and ACK the
|
25
32
|
# master.
|
26
33
|
Einhorn::Worker.graceful_shutdown do
|
27
|
-
|
34
|
+
if sleep_before_shutdown > 0
|
35
|
+
log "sleeping #{sleep_before_shutdown}s before shutdown"
|
36
|
+
sleep sleep_before_shutdown
|
37
|
+
end
|
38
|
+
log "Goodbye!"
|
28
39
|
exit(0)
|
29
40
|
end
|
41
|
+
|
42
|
+
if sleep_before_ack > 0
|
43
|
+
log "sleeping #{sleep_before_ack}s before ack"
|
44
|
+
sleep sleep_before_ack
|
45
|
+
end
|
46
|
+
log "worker ack"
|
47
|
+
|
30
48
|
Einhorn::Worker.ack!
|
31
49
|
|
32
50
|
# Real work happens here.
|
@@ -162,10 +162,10 @@ module Einhorn::Command
|
|
162
162
|
Einhorn::Command.stop_respawning
|
163
163
|
exit(1)
|
164
164
|
end
|
165
|
-
trap_async("HUP") {Einhorn::Command.
|
165
|
+
trap_async("HUP") {Einhorn::Command.full_upgrade_smooth}
|
166
166
|
trap_async("ALRM") do
|
167
167
|
Einhorn.log_error("Upgrading using SIGALRM is deprecated. Please switch to SIGHUP")
|
168
|
-
Einhorn::Command.
|
168
|
+
Einhorn::Command.full_upgrade_smooth
|
169
169
|
end
|
170
170
|
trap_async("CHLD") {}
|
171
171
|
trap_async("USR2") do
|
@@ -292,7 +292,8 @@ EOF
|
|
292
292
|
# Used by einhornsh
|
293
293
|
command 'ehlo' do |conn, request|
|
294
294
|
<<EOF
|
295
|
-
Welcome #{request['user']}! You are speaking to Einhorn Master Process #{$$}#{Einhorn::State.cmd_name ? " (#{Einhorn::State.cmd_name})" : ''}
|
295
|
+
Welcome, #{request['user']}! You are speaking to Einhorn Master Process #{$$}#{Einhorn::State.cmd_name ? " (#{Einhorn::State.cmd_name})" : ''}.
|
296
|
+
This is Einhorn #{Einhorn::VERSION}.
|
296
297
|
EOF
|
297
298
|
end
|
298
299
|
|
@@ -335,14 +336,25 @@ EOF
|
|
335
336
|
Einhorn::Command.louder
|
336
337
|
end
|
337
338
|
|
338
|
-
command 'upgrade', 'Upgrade all Einhorn workers. This may result in Einhorn reloading its own code as well.' do |conn, request|
|
339
|
+
command 'upgrade', 'Upgrade all Einhorn workers smoothly. This may result in Einhorn reloading its own code as well.' do |conn, request|
|
339
340
|
# send first message directly for old clients that don't support request
|
340
341
|
# ids or subscriptions. Everything else is sent tagged with request id
|
341
342
|
# for new clients.
|
342
|
-
send_message(conn, 'Upgrading, as commanded', request['id'])
|
343
|
+
send_message(conn, 'Upgrading smoothly, as commanded', request['id'])
|
343
344
|
conn.subscribe(:upgrade, request['id'])
|
344
345
|
# If the app is preloaded this doesn't return.
|
345
|
-
Einhorn::Command.
|
346
|
+
Einhorn::Command.full_upgrade_smooth
|
347
|
+
nil
|
348
|
+
end
|
349
|
+
|
350
|
+
command 'upgrade_fleet', 'Upgrade all Einhorn workers a fleet at a time. This may result in Einhorn reloading its own code as well.' do |conn, request|
|
351
|
+
# send first message directly for old clients that don't support request
|
352
|
+
# ids or subscriptions. Everything else is sent tagged with request id
|
353
|
+
# for new clients.
|
354
|
+
send_message(conn, 'Upgrading fleet, as commanded', request['id'])
|
355
|
+
conn.subscribe(:upgrade, request['id'])
|
356
|
+
# If the app is preloaded this doesn't return.
|
357
|
+
Einhorn::Command.full_upgrade_fleet
|
346
358
|
nil
|
347
359
|
end
|
348
360
|
|
data/lib/einhorn/command.rb
CHANGED
@@ -232,6 +232,8 @@ module Einhorn
|
|
232
232
|
Einhorn::Event.close_all_for_worker
|
233
233
|
Einhorn.set_argv(cmd, true)
|
234
234
|
|
235
|
+
reseed_random
|
236
|
+
|
235
237
|
prepare_child_environment
|
236
238
|
einhorn_main
|
237
239
|
end
|
@@ -294,11 +296,53 @@ module Einhorn
|
|
294
296
|
ENV['EINHORN_FDS'] = Einhorn::State.bind_fds.map(&:to_s).join(' ')
|
295
297
|
end
|
296
298
|
|
299
|
+
# Reseed common ruby random number generators.
|
300
|
+
#
|
301
|
+
# OpenSSL::Random uses the PID to reseed after fork, which means that if a
|
302
|
+
# long-lived master process over its lifetime spawns two workers with the
|
303
|
+
# same PID, those workers will start with the same OpenSSL seed.
|
304
|
+
#
|
305
|
+
# Ruby >= 1.9 has a guard against this in SecureRandom, but any direct
|
306
|
+
# users of OpenSSL::Random will still be affected.
|
307
|
+
#
|
308
|
+
# Ruby 1.8 didn't even reseed the default random number generator used by
|
309
|
+
# Kernel#rand in certain releases.
|
310
|
+
#
|
311
|
+
# https://bugs.ruby-lang.org/issues/4579
|
312
|
+
#
|
313
|
+
def self.reseed_random
|
314
|
+
# reseed Kernel#rand
|
315
|
+
srand
|
316
|
+
|
317
|
+
# reseed OpenSSL::Random if it's loaded
|
318
|
+
if defined?(OpenSSL::Random)
|
319
|
+
if defined?(Random)
|
320
|
+
seed = Random.new_seed
|
321
|
+
else
|
322
|
+
# Ruby 1.8
|
323
|
+
seed = rand
|
324
|
+
end
|
325
|
+
OpenSSL::Random.seed(seed.to_s)
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
297
329
|
def self.prepare_child_process
|
298
330
|
Einhorn.renice_self
|
299
331
|
end
|
300
332
|
|
301
|
-
|
333
|
+
# @param options [Hash]
|
334
|
+
#
|
335
|
+
# @option options [Boolean] :smooth (false) Whether to perform a smooth or
|
336
|
+
# fleet upgrade. In a smooth upgrade, bring up new workers and cull old
|
337
|
+
# workers one by one as soon as there is a replacement. In a fleet
|
338
|
+
# upgrade, bring up all the new workers and don't cull any old workers
|
339
|
+
# until they're all up.
|
340
|
+
#
|
341
|
+
def self.full_upgrade(options={})
|
342
|
+
options = {:smooth => false}.merge(options)
|
343
|
+
|
344
|
+
Einhorn::State.smooth_upgrade = options.fetch(:smooth)
|
345
|
+
|
302
346
|
if Einhorn::State.path && !Einhorn::State.reloading_for_preload_upgrade
|
303
347
|
reload_for_preload_upgrade
|
304
348
|
else
|
@@ -306,6 +350,13 @@ module Einhorn
|
|
306
350
|
end
|
307
351
|
end
|
308
352
|
|
353
|
+
def self.full_upgrade_smooth
|
354
|
+
full_upgrade(:smooth => true)
|
355
|
+
end
|
356
|
+
def self.full_upgrade_fleet
|
357
|
+
full_upgrade(:smooth => false)
|
358
|
+
end
|
359
|
+
|
309
360
|
def self.reload_for_preload_upgrade
|
310
361
|
Einhorn::State.reloading_for_preload_upgrade = true
|
311
362
|
reload
|
@@ -316,7 +367,9 @@ module Einhorn
|
|
316
367
|
Einhorn.log_info("Currently upgrading (#{Einhorn::WorkerPool.ack_count} / #{Einhorn::WorkerPool.ack_target} ACKs; bumping version and starting over)...", :upgrade)
|
317
368
|
else
|
318
369
|
Einhorn::State.upgrading = true
|
319
|
-
|
370
|
+
u_type = Einhorn::State.smooth_upgrade ? 'smooth' : 'fleet'
|
371
|
+
Einhorn.log_info("Starting #{u_type} upgrade from version" +
|
372
|
+
" #{Einhorn::State.version}...", :upgrade)
|
320
373
|
end
|
321
374
|
|
322
375
|
# Reset this, since we've just upgraded to a new universe (I'm
|
@@ -326,7 +379,11 @@ module Einhorn
|
|
326
379
|
Einhorn::State.last_upgraded = Time.now
|
327
380
|
|
328
381
|
Einhorn::State.version += 1
|
329
|
-
|
382
|
+
if Einhorn::State.smooth_upgrade
|
383
|
+
replenish_gradually
|
384
|
+
else
|
385
|
+
replenish_immediately
|
386
|
+
end
|
330
387
|
end
|
331
388
|
|
332
389
|
def self.cull
|
@@ -341,9 +398,20 @@ module Einhorn
|
|
341
398
|
end
|
342
399
|
|
343
400
|
old_workers = Einhorn::WorkerPool.old_workers
|
401
|
+
Einhorn.log_debug("#{acked} acked, #{unsignaled} unsignaled, #{target} target, #{old_workers.length} old workers")
|
344
402
|
if !Einhorn::State.upgrading && old_workers.length > 0
|
345
403
|
Einhorn.log_info("Killing off #{old_workers.length} old workers.")
|
346
404
|
signal_all("USR2", old_workers)
|
405
|
+
elsif Einhorn::State.upgrading && Einhorn::State.smooth_upgrade
|
406
|
+
# In a smooth upgrade, kill off old workers one by one when we have
|
407
|
+
# sufficiently many new workers.
|
408
|
+
excess = (old_workers.length + acked) - target
|
409
|
+
if excess > 0
|
410
|
+
Einhorn.log_info("Smooth upgrade: killing off #{excess} old workers.")
|
411
|
+
signal_all("USR2", old_workers.take(excess))
|
412
|
+
elsif excess < 0
|
413
|
+
Einhorn.log_error("Smooth upgrade: somehow excess is #{excess}!")
|
414
|
+
end
|
347
415
|
end
|
348
416
|
|
349
417
|
if unsignaled > target
|
@@ -378,25 +446,45 @@ module Einhorn
|
|
378
446
|
missing.times {spinup}
|
379
447
|
end
|
380
448
|
|
381
|
-
def self.replenish_gradually
|
449
|
+
def self.replenish_gradually(max_unacked=nil)
|
382
450
|
return if Einhorn::TransientState.has_outstanding_spinup_timer
|
383
451
|
return unless Einhorn::WorkerPool.missing_worker_count > 0
|
384
452
|
|
453
|
+
# default to spinning up at most NCPU workers at once
|
454
|
+
unless max_unacked
|
455
|
+
begin
|
456
|
+
@processor_count ||= Einhorn::Compat.processor_count
|
457
|
+
rescue => err
|
458
|
+
Einhorn.log_error(err.inspect)
|
459
|
+
@processor_count = 1
|
460
|
+
end
|
461
|
+
max_unacked = @processor_count
|
462
|
+
end
|
463
|
+
|
464
|
+
if max_unacked <= 0
|
465
|
+
raise ArgumentError.new("max_unacked must be positive")
|
466
|
+
end
|
467
|
+
|
385
468
|
# Exponentially backoff automated spinup if we're just having
|
386
469
|
# things die before ACKing
|
387
470
|
spinup_interval = Einhorn::State.config[:seconds] * (1.5 ** Einhorn::State.consecutive_deaths_before_ack)
|
388
471
|
seconds_ago = (Time.now - Einhorn::State.last_spinup).to_f
|
389
472
|
|
390
473
|
if seconds_ago > spinup_interval
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
Einhorn.log_info("#{msg} (there have been #{Einhorn::State.consecutive_deaths_before_ack} consecutive unacked worker deaths)")
|
474
|
+
unacked = Einhorn::WorkerPool.unacked_unsignaled_modern_workers.length
|
475
|
+
if unacked >= max_unacked
|
476
|
+
Einhorn.log_debug("There are #{unacked} unacked new workers, and max_unacked is #{max_unacked}, so not spinning up a new process")
|
395
477
|
else
|
396
|
-
|
397
|
-
end
|
478
|
+
msg = "Last spinup was #{seconds_ago}s ago, and spinup_interval is #{spinup_interval}s, so spinning up a new process"
|
398
479
|
|
399
|
-
|
480
|
+
if Einhorn::State.consecutive_deaths_before_ack > 0
|
481
|
+
Einhorn.log_info("#{msg} (there have been #{Einhorn::State.consecutive_deaths_before_ack} consecutive unacked worker deaths)")
|
482
|
+
else
|
483
|
+
Einhorn.log_debug(msg)
|
484
|
+
end
|
485
|
+
|
486
|
+
spinup
|
487
|
+
end
|
400
488
|
else
|
401
489
|
Einhorn.log_debug("Last spinup was #{seconds_ago}s ago, and spinup_interval is #{spinup_interval}s, so not spinning up a new process")
|
402
490
|
end
|
data/lib/einhorn/compat.rb
CHANGED
@@ -44,5 +44,41 @@ module Einhorn
|
|
44
44
|
cloexec!(sock, false)
|
45
45
|
sock
|
46
46
|
end
|
47
|
+
|
48
|
+
def self.processor_count
|
49
|
+
# jruby
|
50
|
+
if defined? Java::Java
|
51
|
+
return Java::Java.lang.Runtime.getRuntime.availableProcessors
|
52
|
+
end
|
53
|
+
|
54
|
+
# linux / friends
|
55
|
+
begin
|
56
|
+
return File.read('/proc/cpuinfo').scan(/^processor\s*:/).count
|
57
|
+
rescue Errno::ENOENT
|
58
|
+
end
|
59
|
+
|
60
|
+
# OS X
|
61
|
+
if RUBY_PLATFORM =~ /darwin/
|
62
|
+
return Integer(`sysctl -n hw.logicalcpu`)
|
63
|
+
end
|
64
|
+
|
65
|
+
# windows / friends
|
66
|
+
begin
|
67
|
+
require 'win32ole'
|
68
|
+
rescue LoadError
|
69
|
+
else
|
70
|
+
wmi = WIN32OLE.connect("winmgmts://")
|
71
|
+
wmi.ExecQuery("select * from Win32_ComputerSystem").each do |system|
|
72
|
+
begin
|
73
|
+
processors = system.NumberOfLogicalProcessors
|
74
|
+
rescue
|
75
|
+
processors = 0
|
76
|
+
end
|
77
|
+
return [system.NumberOfProcessors, processors].max
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
raise "Failed to detect number of CPUs"
|
82
|
+
end
|
47
83
|
end
|
48
84
|
end
|
data/lib/einhorn/version.rb
CHANGED
data/lib/einhorn/worker_pool.rb
CHANGED
@@ -52,6 +52,16 @@ module Einhorn
|
|
52
52
|
end.map {|pid, _| pid}
|
53
53
|
end
|
54
54
|
|
55
|
+
def self.unacked_unsignaled_modern_workers_with_state
|
56
|
+
modern_workers_with_state.select {|pid, spec|
|
57
|
+
!spec[:acked] && spec[:signaled].length == 0
|
58
|
+
}
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.unacked_unsignaled_modern_workers
|
62
|
+
unacked_unsignaled_modern_workers_with_state.map {|pid, _| pid}
|
63
|
+
end
|
64
|
+
|
55
65
|
# Use the number of modern workers, rather than unsignaled modern
|
56
66
|
# workers. This means if e.g. we do bunch of decs and then incs,
|
57
67
|
# any workers which haven't died yet will count towards our number
|
data/lib/einhorn.rb
CHANGED
@@ -12,7 +12,7 @@ class InterfaceTest < EinhornTestCase
|
|
12
12
|
# Remove trailing newline
|
13
13
|
message = message[0...-1]
|
14
14
|
parsed = YAML.load(URI.unescape(message))
|
15
|
-
parsed['message'] =~ /Welcome gdb/
|
15
|
+
parsed['message'] =~ /Welcome, gdb/
|
16
16
|
end
|
17
17
|
request = {
|
18
18
|
'command' => 'ehlo',
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: einhorn
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.5.
|
4
|
+
version: 0.5.5
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2014-
|
12
|
+
date: 2014-03-25 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: rake
|
@@ -28,7 +28,7 @@ dependencies:
|
|
28
28
|
- !ruby/object:Gem::Version
|
29
29
|
version: '0'
|
30
30
|
- !ruby/object:Gem::Dependency
|
31
|
-
name:
|
31
|
+
name: pry
|
32
32
|
requirement: !ruby/object:Gem::Requirement
|
33
33
|
none: false
|
34
34
|
requirements:
|
@@ -44,39 +44,39 @@ dependencies:
|
|
44
44
|
- !ruby/object:Gem::Version
|
45
45
|
version: '0'
|
46
46
|
- !ruby/object:Gem::Dependency
|
47
|
-
name:
|
47
|
+
name: minitest
|
48
48
|
requirement: !ruby/object:Gem::Requirement
|
49
49
|
none: false
|
50
50
|
requirements:
|
51
|
-
- -
|
51
|
+
- - <
|
52
52
|
- !ruby/object:Gem::Version
|
53
|
-
version: '0'
|
53
|
+
version: '5.0'
|
54
54
|
type: :development
|
55
55
|
prerelease: false
|
56
56
|
version_requirements: !ruby/object:Gem::Requirement
|
57
57
|
none: false
|
58
58
|
requirements:
|
59
|
-
- -
|
59
|
+
- - <
|
60
60
|
- !ruby/object:Gem::Version
|
61
|
-
version: '0'
|
61
|
+
version: '5.0'
|
62
62
|
- !ruby/object:Gem::Dependency
|
63
|
-
name:
|
63
|
+
name: mocha
|
64
64
|
requirement: !ruby/object:Gem::Requirement
|
65
65
|
none: false
|
66
66
|
requirements:
|
67
|
-
- -
|
67
|
+
- - ~>
|
68
68
|
- !ruby/object:Gem::Version
|
69
|
-
version: '0'
|
69
|
+
version: '0.13'
|
70
70
|
type: :development
|
71
71
|
prerelease: false
|
72
72
|
version_requirements: !ruby/object:Gem::Requirement
|
73
73
|
none: false
|
74
74
|
requirements:
|
75
|
-
- -
|
75
|
+
- - ~>
|
76
76
|
- !ruby/object:Gem::Version
|
77
|
-
version: '0'
|
77
|
+
version: '0.13'
|
78
78
|
- !ruby/object:Gem::Dependency
|
79
|
-
name: rake
|
79
|
+
name: chalk-rake
|
80
80
|
requirement: !ruby/object:Gem::Requirement
|
81
81
|
none: false
|
82
82
|
requirements:
|
@@ -91,38 +91,6 @@ dependencies:
|
|
91
91
|
- - ! '>='
|
92
92
|
- !ruby/object:Gem::Version
|
93
93
|
version: '0'
|
94
|
-
- !ruby/object:Gem::Dependency
|
95
|
-
name: minitest
|
96
|
-
requirement: !ruby/object:Gem::Requirement
|
97
|
-
none: false
|
98
|
-
requirements:
|
99
|
-
- - <
|
100
|
-
- !ruby/object:Gem::Version
|
101
|
-
version: '5.0'
|
102
|
-
type: :development
|
103
|
-
prerelease: false
|
104
|
-
version_requirements: !ruby/object:Gem::Requirement
|
105
|
-
none: false
|
106
|
-
requirements:
|
107
|
-
- - <
|
108
|
-
- !ruby/object:Gem::Version
|
109
|
-
version: '5.0'
|
110
|
-
- !ruby/object:Gem::Dependency
|
111
|
-
name: mocha
|
112
|
-
requirement: !ruby/object:Gem::Requirement
|
113
|
-
none: false
|
114
|
-
requirements:
|
115
|
-
- - ~>
|
116
|
-
- !ruby/object:Gem::Version
|
117
|
-
version: '0.13'
|
118
|
-
type: :development
|
119
|
-
prerelease: false
|
120
|
-
version_requirements: !ruby/object:Gem::Requirement
|
121
|
-
none: false
|
122
|
-
requirements:
|
123
|
-
- - ~>
|
124
|
-
- !ruby/object:Gem::Version
|
125
|
-
version: '0.13'
|
126
94
|
description: Einhorn makes it easy to run multiple instances of an application server,
|
127
95
|
all listening on the same port. You can also seamlessly restart your workers without
|
128
96
|
dropping any requests. Einhorn requires minimal application-level support, making
|
@@ -198,7 +166,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
198
166
|
version: '0'
|
199
167
|
segments:
|
200
168
|
- 0
|
201
|
-
hash: -
|
169
|
+
hash: -690549708583071819
|
202
170
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
203
171
|
none: false
|
204
172
|
requirements:
|
@@ -207,7 +175,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
207
175
|
version: '0'
|
208
176
|
segments:
|
209
177
|
- 0
|
210
|
-
hash: -
|
178
|
+
hash: -690549708583071819
|
211
179
|
requirements: []
|
212
180
|
rubyforge_project:
|
213
181
|
rubygems_version: 1.8.23
|