throttle_machines 0.2.4-x86_64-linux → 0.3.1-x86_64-linux
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.
- checksums.yaml +4 -4
- data/lib/throttle_machines/storage/base.rb +20 -0
- data/lib/throttle_machines/storage/memory.rb +6 -6
- data/lib/throttle_machines/storage/redis/gcra.lua +6 -2
- data/lib/throttle_machines/storage/redis/peek_gcra.lua +6 -2
- data/lib/throttle_machines/storage/redis/peek_token_bucket.lua +9 -3
- data/lib/throttle_machines/storage/redis/token_bucket.lua +7 -3
- data/lib/throttle_machines/storage/redis.rb +49 -94
- data/lib/throttle_machines/version.rb +1 -1
- data/lib/throttle_machines_native/throttle_machines_native.so +0 -0
- metadata +10 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c0e7b82b0a0e6aee13b28e5dff8d398583123ae16859094db1f5731563b6756a
|
|
4
|
+
data.tar.gz: 55eae369f9ff8d44e771ce1d30af98e81468c4e4590cb283121236dad134d726
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9e2199d9db95a4b57fc42ed9d702a464e7efccc6a43312ef0e46e33b16cba5ec19bf6a1b684a8eaab2463d0ae89a4fcae063571a198a48b2d53a526f7a5730c5
|
|
7
|
+
data.tar.gz: b10d373aa0a749dddea669a8f50d1118c3101fb68b84a1411eed792457edae55e0f8cb0ecf43a389da389bc5884a9cead734df1a4a89687eb1027765ddc2bd56
|
|
@@ -57,8 +57,28 @@ module ThrottleMachines
|
|
|
57
57
|
nil
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# Rounding slack for time comparisons, in units of the clock's precision.
|
|
61
|
+
# Matches the native core.
|
|
62
|
+
SLACK_ULPS = 64
|
|
63
|
+
|
|
60
64
|
protected
|
|
61
65
|
|
|
66
|
+
# Times are running sums and differences of float seconds, so an
|
|
67
|
+
# on-schedule arrival can miss an exact boundary by a few ulps. The slack
|
|
68
|
+
# scales with the clock's magnitude.
|
|
69
|
+
def rounding_slack(time)
|
|
70
|
+
Float::EPSILON * SLACK_ULPS * [time.abs, 1.0].max
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def gcra_allowed?(tat, now, delay_tolerance)
|
|
74
|
+
tat - now <= delay_tolerance + rounding_slack(tat)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Whether a token is available, allowing for rounding in the refill.
|
|
78
|
+
def token_available?(tokens, now, refill_rate)
|
|
79
|
+
tokens >= 1 - (rounding_slack(now) * refill_rate)
|
|
80
|
+
end
|
|
81
|
+
|
|
62
82
|
def current_time
|
|
63
83
|
# Use monotonic time for consistency with BreakerMachines
|
|
64
84
|
ThrottleMachines.monotonic_time
|
|
@@ -86,7 +86,7 @@ module ThrottleMachines
|
|
|
86
86
|
state = @gcra_states[key] || { tat: 0.0 }
|
|
87
87
|
|
|
88
88
|
tat = [state[:tat], now].max
|
|
89
|
-
allow = tat
|
|
89
|
+
allow = gcra_allowed?(tat, now, delay_tolerance)
|
|
90
90
|
|
|
91
91
|
if allow
|
|
92
92
|
new_tat = tat + emission_interval
|
|
@@ -107,7 +107,7 @@ module ThrottleMachines
|
|
|
107
107
|
state = @gcra_states[key] || { tat: 0.0 }
|
|
108
108
|
|
|
109
109
|
tat = [state[:tat], now].max
|
|
110
|
-
allow = tat
|
|
110
|
+
allow = gcra_allowed?(tat, now, delay_tolerance)
|
|
111
111
|
|
|
112
112
|
{
|
|
113
113
|
allowed: allow,
|
|
@@ -130,8 +130,8 @@ module ThrottleMachines
|
|
|
130
130
|
bucket[:last_refill] = now
|
|
131
131
|
|
|
132
132
|
# Check if we can consume a token
|
|
133
|
-
if bucket[:tokens]
|
|
134
|
-
bucket[:tokens]
|
|
133
|
+
if token_available?(bucket[:tokens], now, refill_rate)
|
|
134
|
+
bucket[:tokens] = [bucket[:tokens] - 1, 0.0].max
|
|
135
135
|
@token_buckets[key] = bucket.merge(expires_at: now + ttl)
|
|
136
136
|
|
|
137
137
|
{
|
|
@@ -161,11 +161,11 @@ module ThrottleMachines
|
|
|
161
161
|
tokens_to_add = elapsed * refill_rate
|
|
162
162
|
current_tokens = [bucket[:tokens] + tokens_to_add, capacity].min
|
|
163
163
|
|
|
164
|
-
if current_tokens
|
|
164
|
+
if token_available?(current_tokens, now, refill_rate)
|
|
165
165
|
{
|
|
166
166
|
allowed: true,
|
|
167
167
|
retry_after: 0,
|
|
168
|
-
tokens_remaining:
|
|
168
|
+
tokens_remaining: [current_tokens - 1, 0].max.floor
|
|
169
169
|
}
|
|
170
170
|
else
|
|
171
171
|
retry_after = (1 - current_tokens) / refill_rate
|
|
@@ -12,11 +12,15 @@ else
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
tat = math.max(tat, now)
|
|
15
|
-
|
|
15
|
+
-- The TAT is a running sum of emission intervals, so an on-schedule arrival
|
|
16
|
+
-- can overshoot the tolerance by a few ulps; the slack scales with the clock.
|
|
17
|
+
local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(tat), 1)
|
|
18
|
+
local allow = (tat - now) <= delay_tolerance + slack
|
|
16
19
|
|
|
17
20
|
if allow then
|
|
18
21
|
local new_tat = tat + emission_interval
|
|
19
22
|
redis.call('SET', key, new_tat, 'EX', ttl)
|
|
20
23
|
end
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
-- Redis truncates Lua numbers in replies to integers; send the TAT as text.
|
|
26
|
+
return { allow and 1 or 0, string.format('%.17g', tat) }
|
|
@@ -11,6 +11,10 @@ else
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
tat = math.max(tat, now)
|
|
14
|
-
|
|
14
|
+
-- The TAT is a running sum of emission intervals, so an on-schedule arrival
|
|
15
|
+
-- can overshoot the tolerance by a few ulps; the slack scales with the clock.
|
|
16
|
+
local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(tat), 1)
|
|
17
|
+
local allow = (tat - now) <= delay_tolerance + slack
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
-- Redis truncates Lua numbers in replies to integers; send the TAT as text.
|
|
20
|
+
return { allow and 1 or 0, string.format('%.17g', tat) }
|
|
@@ -12,7 +12,13 @@ local elapsed = now - last_refill
|
|
|
12
12
|
local tokens_to_add = elapsed * refill_rate
|
|
13
13
|
tokens = math.min(tokens + tokens_to_add, capacity)
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
-- `elapsed` carries the clock's rounding; a token due exactly now may come
|
|
16
|
+
-- out a hair short of 1. The slack scales with the clock, as in the native core.
|
|
17
|
+
local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(now), 1) * refill_rate
|
|
18
|
+
local allow = tokens >= 1 - slack
|
|
19
|
+
-- Admitted: tokens left after this request. Refused: the fractional balance,
|
|
20
|
+
-- which retry_after is computed from.
|
|
21
|
+
local tokens_after = allow and math.max(tokens - 1, 0) or tokens
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
-- Redis truncates Lua numbers in replies to integers; send the count as text.
|
|
24
|
+
return { allow and 1 or 0, string.format('%.17g', tokens_after) }
|
|
@@ -13,11 +13,15 @@ local elapsed = now - last_refill
|
|
|
13
13
|
local tokens_to_add = elapsed * refill_rate
|
|
14
14
|
tokens = math.min(tokens + tokens_to_add, capacity)
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
-- `elapsed` carries the clock's rounding; a token due exactly now may come
|
|
17
|
+
-- out a hair short of 1. The slack scales with the clock, as in the native core.
|
|
18
|
+
local slack = 2.220446049250313e-16 * 64 * math.max(math.abs(now), 1) * refill_rate
|
|
19
|
+
local allow = tokens >= 1 - slack
|
|
17
20
|
if allow then
|
|
18
|
-
tokens = tokens - 1
|
|
21
|
+
tokens = math.max(tokens - 1, 0)
|
|
19
22
|
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
|
|
20
23
|
redis.call('EXPIRE', key, ttl)
|
|
21
24
|
end
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
-- Redis truncates Lua numbers in replies to integers; send the count as text.
|
|
27
|
+
return { allow and 1 or 0, string.format('%.17g', tokens) }
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest/sha1'
|
|
4
|
+
|
|
3
5
|
module ThrottleMachines
|
|
4
6
|
module Storage
|
|
5
7
|
class Redis < Base
|
|
@@ -13,17 +15,18 @@ module ThrottleMachines
|
|
|
13
15
|
INCREMENT_COUNTER_SCRIPT = File.read(File.join(LUA_SCRIPTS_DIR, 'increment_counter.lua'))
|
|
14
16
|
# Breaker scripts removed: breaker state is owned by BreakerMachines
|
|
15
17
|
|
|
18
|
+
# Redis caches scripts by the SHA1 of their source, so the SHAs are known
|
|
19
|
+
# without a SCRIPT LOAD round trip.
|
|
20
|
+
GCRA_SHA = Digest::SHA1.hexdigest(GCRA_SCRIPT)
|
|
21
|
+
TOKEN_BUCKET_SHA = Digest::SHA1.hexdigest(TOKEN_BUCKET_SCRIPT)
|
|
22
|
+
PEEK_GCRA_SHA = Digest::SHA1.hexdigest(PEEK_GCRA_SCRIPT)
|
|
23
|
+
PEEK_TOKEN_BUCKET_SHA = Digest::SHA1.hexdigest(PEEK_TOKEN_BUCKET_SCRIPT)
|
|
24
|
+
|
|
16
25
|
def initialize(options = {})
|
|
17
26
|
super
|
|
18
27
|
@redis = options[:redis] || options[:client] || options[:pool]
|
|
19
28
|
@prefix = options[:prefix] || 'throttle:'
|
|
20
29
|
|
|
21
|
-
# Cache scripts to avoid repeated script loads
|
|
22
|
-
@gcra_sha = nil
|
|
23
|
-
@token_bucket_sha = nil
|
|
24
|
-
@peek_gcra_sha = nil
|
|
25
|
-
@peek_token_bucket_sha = nil
|
|
26
|
-
|
|
27
30
|
# Validate Redis connection
|
|
28
31
|
validate_redis_connection!
|
|
29
32
|
end
|
|
@@ -56,111 +59,53 @@ module ThrottleMachines
|
|
|
56
59
|
|
|
57
60
|
# GCRA operations (atomic via Lua)
|
|
58
61
|
def check_gcra_limit(key, emission_interval, delay_tolerance, ttl)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
@gcra_sha,
|
|
64
|
-
keys: [prefixed(key)],
|
|
65
|
-
argv: [emission_interval, delay_tolerance, ttl, current_time]
|
|
66
|
-
)
|
|
67
|
-
end
|
|
62
|
+
now = current_time
|
|
63
|
+
result = run_script(GCRA_SCRIPT, GCRA_SHA,
|
|
64
|
+
keys: [prefixed(key)],
|
|
65
|
+
argv: [emission_interval, delay_tolerance, ttl, now])
|
|
68
66
|
|
|
69
67
|
allowed = result[0] == 1
|
|
70
|
-
tat = result[1]
|
|
71
|
-
now = current_time
|
|
68
|
+
tat = Float(result[1])
|
|
72
69
|
|
|
73
70
|
{
|
|
74
71
|
allowed: allowed,
|
|
75
72
|
retry_after: allowed ? 0 : (tat - now - delay_tolerance),
|
|
76
73
|
tat: tat
|
|
77
74
|
}
|
|
78
|
-
rescue ::Redis::CommandError => e
|
|
79
|
-
raise unless e.message.include?('NOSCRIPT')
|
|
80
|
-
|
|
81
|
-
@gcra_sha = nil
|
|
82
|
-
retry
|
|
83
75
|
end
|
|
84
76
|
|
|
85
77
|
# Token bucket operations (atomic via Lua)
|
|
86
78
|
def check_token_bucket(key, capacity, refill_rate, ttl)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
redis.evalsha(
|
|
91
|
-
@token_bucket_sha,
|
|
92
|
-
keys: [prefixed(key)],
|
|
93
|
-
argv: [capacity, refill_rate, ttl, current_time]
|
|
94
|
-
)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
allowed = result[0] == 1
|
|
98
|
-
tokens = result[1]
|
|
79
|
+
result = run_script(TOKEN_BUCKET_SCRIPT, TOKEN_BUCKET_SHA,
|
|
80
|
+
keys: [prefixed(key)],
|
|
81
|
+
argv: [capacity, refill_rate, ttl, current_time])
|
|
99
82
|
|
|
100
|
-
|
|
101
|
-
allowed: allowed,
|
|
102
|
-
retry_after: allowed ? 0 : (1 - tokens) / refill_rate,
|
|
103
|
-
tokens_remaining: tokens.floor
|
|
104
|
-
}
|
|
105
|
-
rescue ::Redis::CommandError => e
|
|
106
|
-
raise unless e.message.include?('NOSCRIPT')
|
|
107
|
-
|
|
108
|
-
@token_bucket_sha = nil
|
|
109
|
-
retry
|
|
83
|
+
token_bucket_result(result, refill_rate)
|
|
110
84
|
end
|
|
111
85
|
|
|
112
86
|
# Peek methods for non-consuming checks
|
|
113
87
|
def peek_gcra_limit(key, emission_interval, delay_tolerance)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
@peek_gcra_sha,
|
|
119
|
-
keys: [prefixed(key)],
|
|
120
|
-
argv: [emission_interval, delay_tolerance, current_time]
|
|
121
|
-
)
|
|
122
|
-
end
|
|
88
|
+
now = current_time
|
|
89
|
+
result = run_script(PEEK_GCRA_SCRIPT, PEEK_GCRA_SHA,
|
|
90
|
+
keys: [prefixed(key)],
|
|
91
|
+
argv: [emission_interval, delay_tolerance, now])
|
|
123
92
|
|
|
124
93
|
allowed = result[0] == 1
|
|
125
|
-
tat = result[1]
|
|
126
|
-
now = current_time
|
|
94
|
+
tat = Float(result[1])
|
|
127
95
|
|
|
128
96
|
{
|
|
129
97
|
allowed: allowed,
|
|
130
98
|
retry_after: allowed ? 0 : (tat - now - delay_tolerance),
|
|
131
99
|
tat: tat
|
|
132
100
|
}
|
|
133
|
-
rescue ::Redis::CommandError => e
|
|
134
|
-
raise unless e.message.include?('NOSCRIPT')
|
|
135
|
-
|
|
136
|
-
@peek_gcra_sha = nil
|
|
137
|
-
retry
|
|
138
101
|
end
|
|
139
102
|
|
|
140
103
|
def peek_token_bucket(key, capacity, refill_rate)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
redis.evalsha(
|
|
145
|
-
@peek_token_bucket_sha,
|
|
146
|
-
keys: [prefixed(key)],
|
|
147
|
-
argv: [capacity, refill_rate, current_time]
|
|
148
|
-
)
|
|
149
|
-
end
|
|
104
|
+
result = run_script(PEEK_TOKEN_BUCKET_SCRIPT, PEEK_TOKEN_BUCKET_SHA,
|
|
105
|
+
keys: [prefixed(key)],
|
|
106
|
+
argv: [capacity, refill_rate, current_time])
|
|
150
107
|
|
|
151
|
-
|
|
152
|
-
tokens_remaining = result[1]
|
|
153
|
-
|
|
154
|
-
{
|
|
155
|
-
allowed: allowed,
|
|
156
|
-
retry_after: allowed ? 0 : (1 - tokens_remaining) / refill_rate,
|
|
157
|
-
tokens_remaining: tokens_remaining.floor
|
|
158
|
-
}
|
|
159
|
-
rescue ::Redis::CommandError => e
|
|
160
|
-
raise unless e.message.include?('NOSCRIPT')
|
|
161
|
-
|
|
162
|
-
@peek_token_bucket_sha = nil
|
|
163
|
-
retry
|
|
108
|
+
token_bucket_result(result, refill_rate)
|
|
164
109
|
end
|
|
165
110
|
|
|
166
111
|
# No circuit breaker operations: breaker state is owned by BreakerMachines
|
|
@@ -188,24 +133,34 @@ module ThrottleMachines
|
|
|
188
133
|
|
|
189
134
|
private
|
|
190
135
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
136
|
+
# `result` is [allowed, tokens]: the balance after an admission, or the
|
|
137
|
+
# fractional balance a refusal waits on. Matches Storage::Memory.
|
|
138
|
+
def token_bucket_result(result, refill_rate)
|
|
139
|
+
allowed = result[0] == 1
|
|
140
|
+
tokens = Float(result[1])
|
|
194
141
|
|
|
195
|
-
|
|
196
|
-
|
|
142
|
+
{
|
|
143
|
+
allowed: allowed,
|
|
144
|
+
retry_after: allowed ? 0 : (1 - tokens) / refill_rate,
|
|
145
|
+
tokens_remaining: allowed ? tokens.floor : 0
|
|
146
|
+
}
|
|
197
147
|
end
|
|
198
148
|
|
|
199
|
-
def
|
|
200
|
-
@
|
|
149
|
+
def prefixed(key)
|
|
150
|
+
"#{@prefix}#{key}"
|
|
201
151
|
end
|
|
202
152
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
153
|
+
# EVALSHA by the precomputed SHA. A server without the script cached (a
|
|
154
|
+
# restart, SCRIPT FLUSH, failover) answers NOSCRIPT; EVAL then runs the
|
|
155
|
+
# source once and caches it for later calls. Any other error propagates.
|
|
156
|
+
def run_script(script, sha, keys:, argv:)
|
|
157
|
+
with_redis do |redis|
|
|
158
|
+
redis.evalsha(sha, keys: keys, argv: argv)
|
|
159
|
+
rescue ::Redis::CommandError => e
|
|
160
|
+
raise unless e.message.include?('NOSCRIPT')
|
|
206
161
|
|
|
207
|
-
|
|
208
|
-
|
|
162
|
+
redis.eval(script, keys: keys, argv: argv)
|
|
163
|
+
end
|
|
209
164
|
end
|
|
210
165
|
|
|
211
166
|
def validate_redis_connection!
|
|
Binary file
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: throttle_machines
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: x86_64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -16,14 +16,14 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 8.
|
|
19
|
+
version: '8.1'
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 8.
|
|
26
|
+
version: '8.1'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: concurrent-ruby
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -72,42 +72,42 @@ dependencies:
|
|
|
72
72
|
requirements:
|
|
73
73
|
- - "~>"
|
|
74
74
|
- !ruby/object:Gem::Version
|
|
75
|
-
version: '0.
|
|
75
|
+
version: '0.20'
|
|
76
76
|
type: :runtime
|
|
77
77
|
prerelease: false
|
|
78
78
|
version_requirements: !ruby/object:Gem::Requirement
|
|
79
79
|
requirements:
|
|
80
80
|
- - "~>"
|
|
81
81
|
- !ruby/object:Gem::Version
|
|
82
|
-
version: '0.
|
|
82
|
+
version: '0.20'
|
|
83
83
|
- !ruby/object:Gem::Dependency
|
|
84
84
|
name: chrono_machines
|
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
|
86
86
|
requirements:
|
|
87
87
|
- - "~>"
|
|
88
88
|
- !ruby/object:Gem::Version
|
|
89
|
-
version: '0.
|
|
89
|
+
version: '0.10'
|
|
90
90
|
type: :runtime
|
|
91
91
|
prerelease: false
|
|
92
92
|
version_requirements: !ruby/object:Gem::Requirement
|
|
93
93
|
requirements:
|
|
94
94
|
- - "~>"
|
|
95
95
|
- !ruby/object:Gem::Version
|
|
96
|
-
version: '0.
|
|
96
|
+
version: '0.10'
|
|
97
97
|
- !ruby/object:Gem::Dependency
|
|
98
98
|
name: minitest
|
|
99
99
|
requirement: !ruby/object:Gem::Requirement
|
|
100
100
|
requirements:
|
|
101
101
|
- - "~>"
|
|
102
102
|
- !ruby/object:Gem::Version
|
|
103
|
-
version: '
|
|
103
|
+
version: '6.0'
|
|
104
104
|
type: :development
|
|
105
105
|
prerelease: false
|
|
106
106
|
version_requirements: !ruby/object:Gem::Requirement
|
|
107
107
|
requirements:
|
|
108
108
|
- - "~>"
|
|
109
109
|
- !ruby/object:Gem::Version
|
|
110
|
-
version: '
|
|
110
|
+
version: '6.0'
|
|
111
111
|
- !ruby/object:Gem::Dependency
|
|
112
112
|
name: rake
|
|
113
113
|
requirement: !ruby/object:Gem::Requirement
|