breaker_machines 0.18.0 → 0.20.0
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/breaker_machines/circuit/admission.rb +18 -4
- data/lib/breaker_machines/circuit/base.rb +41 -3
- data/lib/breaker_machines/circuit/native.rb +14 -16
- data/lib/breaker_machines/circuit/state_callbacks.rb +3 -3
- data/lib/breaker_machines/storage/base.rb +2 -0
- data/lib/breaker_machines/storage/cache.rb +66 -17
- data/lib/breaker_machines/storage/memory.rb +5 -3
- data/lib/breaker_machines/version.rb +1 -1
- data/lib/breaker_machines.rb +29 -0
- metadata +7 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a533c91306760488de32d6ef3b3a3bfc9a6c874b1b6e77c6fed42b429a2da6b6
|
|
4
|
+
data.tar.gz: 88991049dc9e83392a13880557ea199830291d21213749a84cd3716c56c9eaac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c25929aa77bacbca4857bc1961110680838e2ae83fee1356d10f3f67297e623f2b1bafe0a104485a372618281d45e542da063482e4ead55294fe9f2e7655478
|
|
7
|
+
data.tar.gz: 7ef7f97ca50b64a56589ff6a3a062fa4016e33b70e81e3becd3fb705254a3ffbab6af89e75a6d8f0fc17f2c8a3784b48dbdaba4d8fe78383ca244fe15e7a0a5f
|
|
@@ -18,14 +18,28 @@ module BreakerMachines
|
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
|
+
# Runs on every call: the write lock is taken only to apply a changed
|
|
22
|
+
# shared status or to start recovery.
|
|
21
23
|
def attempt_recovery_if_ready
|
|
22
|
-
|
|
24
|
+
changed = stored_status_changed?
|
|
25
|
+
return unless changed || (open? && reset_timeout_elapsed?)
|
|
23
26
|
|
|
24
27
|
@mutex.with_write_lock do
|
|
28
|
+
restore_status_from_storage if changed
|
|
25
29
|
attempt_recovery if open? && reset_timeout_elapsed?
|
|
26
30
|
end
|
|
27
31
|
end
|
|
28
32
|
|
|
33
|
+
# Reads the shared status before taking the write lock, so storage I/O
|
|
34
|
+
# happens under it only when there is a change to apply.
|
|
35
|
+
def with_refreshed_status
|
|
36
|
+
changed = stored_status_changed?
|
|
37
|
+
@mutex.with_write_lock do
|
|
38
|
+
restore_status_from_storage if changed
|
|
39
|
+
yield
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
29
43
|
def admit_call
|
|
30
44
|
@mutex.with_read_lock do
|
|
31
45
|
case status_name
|
|
@@ -55,7 +69,7 @@ module BreakerMachines
|
|
|
55
69
|
def handle_success(admission)
|
|
56
70
|
return unless admission.half_open?
|
|
57
71
|
|
|
58
|
-
|
|
72
|
+
with_refreshed_status do
|
|
59
73
|
return unless admission_current?(admission) && half_open?
|
|
60
74
|
|
|
61
75
|
successful_attempts = @half_open_successes.increment
|
|
@@ -68,7 +82,7 @@ module BreakerMachines
|
|
|
68
82
|
end
|
|
69
83
|
|
|
70
84
|
def handle_failure(admission)
|
|
71
|
-
|
|
85
|
+
with_refreshed_status do
|
|
72
86
|
return unless admission_current?(admission)
|
|
73
87
|
|
|
74
88
|
if admission.closed? && closed? && failure_threshold_exceeded?
|
|
@@ -84,7 +98,7 @@ module BreakerMachines
|
|
|
84
98
|
def release_abandoned_admission(admission)
|
|
85
99
|
return unless admission.half_open?
|
|
86
100
|
|
|
87
|
-
|
|
101
|
+
with_refreshed_status do
|
|
88
102
|
next unless admission_current?(admission) && half_open?
|
|
89
103
|
next unless @half_open_attempts.value.positive?
|
|
90
104
|
|
|
@@ -53,12 +53,50 @@ module BreakerMachines
|
|
|
53
53
|
@semaphore = (Concurrent::Semaphore.new(@config[:max_concurrent]) if @config[:max_concurrent])
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
# A round trip through epoch time is off by up to ~0.12µs (float64
|
|
57
|
+
# spacing near today's epoch); any larger difference is another opening.
|
|
58
|
+
OPENED_AT_TOLERANCE = 1e-6
|
|
59
|
+
|
|
60
|
+
# Reads the shared status without taking the lock. True when it differs
|
|
61
|
+
# from the last status this instance stored or observed.
|
|
62
|
+
def stored_status_changed?
|
|
63
|
+
stored_status = @storage&.get_status(@name)
|
|
64
|
+
!stored_status.nil? && !same_stored_status?(stored_status, @stored_status)
|
|
65
|
+
end
|
|
66
|
+
|
|
56
67
|
def restore_status_from_storage
|
|
57
|
-
stored_status = @storage
|
|
58
|
-
return
|
|
68
|
+
stored_status = @storage&.get_status(@name)
|
|
69
|
+
return if stored_status.nil? || same_stored_status?(stored_status, @stored_status)
|
|
59
70
|
|
|
71
|
+
@stored_status = stored_status
|
|
60
72
|
self.status = stored_status.status.to_s
|
|
61
|
-
@opened_at.value = stored_status.opened_at
|
|
73
|
+
@opened_at.value = stored_status.opened_at
|
|
74
|
+
@state_epoch.increment
|
|
75
|
+
@half_open_attempts.value = 0
|
|
76
|
+
@half_open_successes.value = 0
|
|
77
|
+
return unless closed?
|
|
78
|
+
|
|
79
|
+
@last_error.value = nil
|
|
80
|
+
@last_failure_at.value = nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def same_stored_status?(stored_status, known_status)
|
|
84
|
+
return false unless known_status && stored_status.status == known_status.status
|
|
85
|
+
|
|
86
|
+
stored_at = stored_status.opened_at
|
|
87
|
+
known_at = known_status.opened_at
|
|
88
|
+
return stored_at == known_at if stored_at.nil? || known_at.nil?
|
|
89
|
+
|
|
90
|
+
(stored_at - known_at).abs <= OPENED_AT_TOLERANCE
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# When the storage reports a failed write (false), keep the last status
|
|
94
|
+
# observed there: the stale entry then reads as unchanged and cannot
|
|
95
|
+
# undo this transition.
|
|
96
|
+
def persist_status(status, opened_at = nil)
|
|
97
|
+
return if @storage.nil? || @storage.set_status(@name, status, opened_at) == false
|
|
98
|
+
|
|
99
|
+
@stored_status = BreakerMachines::Status.new(status: status, opened_at: opened_at)
|
|
62
100
|
end
|
|
63
101
|
end
|
|
64
102
|
end
|
|
@@ -57,23 +57,15 @@ module BreakerMachines
|
|
|
57
57
|
|
|
58
58
|
# Execute a block with circuit breaker protection
|
|
59
59
|
#
|
|
60
|
+
# Admission, the open -> half-open cooldown, and half-open probe limits run
|
|
61
|
+
# in the native circuit. A StandardError counts as a failure and is
|
|
62
|
+
# re-raised; other exceptions and throw/break pass through uncounted.
|
|
63
|
+
#
|
|
60
64
|
# @yield Block to execute
|
|
61
65
|
# @return Result of the block
|
|
62
|
-
# @raise [CircuitOpenError] if circuit is open
|
|
63
|
-
def call
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
start_time = BreakerMachines.monotonic_time
|
|
67
|
-
begin
|
|
68
|
-
result = yield
|
|
69
|
-
duration = BreakerMachines.monotonic_time - start_time
|
|
70
|
-
@native_circuit.record_success(duration)
|
|
71
|
-
result
|
|
72
|
-
rescue StandardError => _e
|
|
73
|
-
duration = BreakerMachines.monotonic_time - start_time
|
|
74
|
-
@native_circuit.record_failure(duration)
|
|
75
|
-
raise
|
|
76
|
-
end
|
|
66
|
+
# @raise [CircuitOpenError] if circuit is open or its probe slots are taken
|
|
67
|
+
def call(&)
|
|
68
|
+
@native_circuit.call(&)
|
|
77
69
|
end
|
|
78
70
|
|
|
79
71
|
# Check if circuit is open
|
|
@@ -88,8 +80,14 @@ module BreakerMachines
|
|
|
88
80
|
@native_circuit.is_closed
|
|
89
81
|
end
|
|
90
82
|
|
|
83
|
+
# Check if circuit is half-open (probing for recovery)
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def half_open?
|
|
86
|
+
@native_circuit.is_half_open
|
|
87
|
+
end
|
|
88
|
+
|
|
91
89
|
# Get current state name
|
|
92
|
-
# @return [String] 'open' or '
|
|
90
|
+
# @return [String] 'closed', 'open', or 'half_open'
|
|
93
91
|
def state
|
|
94
92
|
@native_circuit.state_name
|
|
95
93
|
end
|
|
@@ -11,7 +11,7 @@ module BreakerMachines
|
|
|
11
11
|
def on_circuit_open
|
|
12
12
|
@state_epoch.increment
|
|
13
13
|
@opened_at.value = BreakerMachines.monotonic_time
|
|
14
|
-
|
|
14
|
+
persist_status(:open, @opened_at.value)
|
|
15
15
|
if @storage.respond_to?(:record_event_with_details)
|
|
16
16
|
@storage.record_event_with_details(@name, :state_change, 0,
|
|
17
17
|
new_state: :open)
|
|
@@ -25,7 +25,7 @@ module BreakerMachines
|
|
|
25
25
|
@opened_at.value = nil
|
|
26
26
|
@last_error.value = nil
|
|
27
27
|
@last_failure_at.value = nil
|
|
28
|
-
|
|
28
|
+
persist_status(:closed)
|
|
29
29
|
if @storage.respond_to?(:record_event_with_details)
|
|
30
30
|
@storage.record_event_with_details(@name, :state_change, 0,
|
|
31
31
|
new_state: :closed)
|
|
@@ -38,7 +38,7 @@ module BreakerMachines
|
|
|
38
38
|
@state_epoch.increment
|
|
39
39
|
@half_open_attempts.value = 0
|
|
40
40
|
@half_open_successes.value = 0
|
|
41
|
-
|
|
41
|
+
persist_status(:half_open, @opened_at.value)
|
|
42
42
|
if @storage.respond_to?(:record_event_with_details)
|
|
43
43
|
@storage.record_event_with_details(@name, :state_change, 0,
|
|
44
44
|
new_state: :half_open)
|
|
@@ -13,6 +13,8 @@ module BreakerMachines
|
|
|
13
13
|
raise NotImplementedError
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# opened_at is a BreakerMachines.monotonic_time value. Return false when
|
|
17
|
+
# the write did not happen; any other value means it was stored.
|
|
16
18
|
def set_status(circuit_name, status, opened_at = nil)
|
|
17
19
|
raise NotImplementedError
|
|
18
20
|
end
|
|
@@ -10,6 +10,12 @@ module BreakerMachines
|
|
|
10
10
|
@cache = cache_store
|
|
11
11
|
@prefix = options[:prefix] || 'breaker_machines'
|
|
12
12
|
@expires_in = options[:expires_in] || 300 # 5 minutes default
|
|
13
|
+
# Counter resolution in seconds. Each bucket is its own cache entry, so
|
|
14
|
+
# stores that keep expired entries around (Solid Cache) want coarser buckets.
|
|
15
|
+
@bucket_seconds = Integer(options[:bucket_seconds] || 1)
|
|
16
|
+
raise ArgumentError, 'bucket_seconds must be positive' unless @bucket_seconds.positive?
|
|
17
|
+
|
|
18
|
+
@warned_windows = Concurrent::Set.new
|
|
13
19
|
end
|
|
14
20
|
|
|
15
21
|
def get_status(circuit_name)
|
|
@@ -18,7 +24,7 @@ module BreakerMachines
|
|
|
18
24
|
|
|
19
25
|
BreakerMachines::Status.new(
|
|
20
26
|
status: data[:status].to_sym,
|
|
21
|
-
opened_at: data
|
|
27
|
+
opened_at: local_opened_at(data)
|
|
22
28
|
)
|
|
23
29
|
end
|
|
24
30
|
|
|
@@ -28,6 +34,7 @@ module BreakerMachines
|
|
|
28
34
|
{
|
|
29
35
|
status: status,
|
|
30
36
|
opened_at: opened_at,
|
|
37
|
+
opened_at_epoch: opened_at && BreakerMachines.monotonic_to_epoch(opened_at),
|
|
31
38
|
updated_at: monotonic_time
|
|
32
39
|
},
|
|
33
40
|
expires_in: @expires_in
|
|
@@ -55,6 +62,12 @@ module BreakerMachines
|
|
|
55
62
|
@cache.delete(success_key(circuit_name))
|
|
56
63
|
@cache.delete(failure_key(circuit_name))
|
|
57
64
|
@cache.delete(events_key(circuit_name))
|
|
65
|
+
return unless @cache.respond_to?(:increment)
|
|
66
|
+
|
|
67
|
+
keys = [success_key(circuit_name), failure_key(circuit_name)].flat_map do |key|
|
|
68
|
+
bucket_keys(key, bucket_count(@expires_in) + 1)
|
|
69
|
+
end
|
|
70
|
+
delete_counters(keys)
|
|
58
71
|
end
|
|
59
72
|
|
|
60
73
|
def clear_all
|
|
@@ -103,10 +116,20 @@ module BreakerMachines
|
|
|
103
116
|
|
|
104
117
|
private
|
|
105
118
|
|
|
119
|
+
# Hosts do not share a monotonic clock origin, so other hosts read the
|
|
120
|
+
# epoch copy. Entries written before it existed carry only the writer's
|
|
121
|
+
# monotonic time.
|
|
122
|
+
def local_opened_at(data)
|
|
123
|
+
epoch = data[:opened_at_epoch]
|
|
124
|
+
epoch ? BreakerMachines.epoch_to_monotonic(epoch) : data[:opened_at]
|
|
125
|
+
end
|
|
126
|
+
|
|
106
127
|
def increment_counter(key)
|
|
107
128
|
# Use increment if available, otherwise fetch-and-update
|
|
108
129
|
if @cache.respond_to?(:increment)
|
|
109
|
-
|
|
130
|
+
# Expiry counts from the first increment, so a bucket must outlive
|
|
131
|
+
# the retention period by its own length.
|
|
132
|
+
@cache.increment("#{key}:#{current_bucket}", 1, expires_in: @expires_in + @bucket_seconds)
|
|
110
133
|
else
|
|
111
134
|
# Fallback for caches without atomic increment
|
|
112
135
|
current = @cache.fetch(key) { {} }
|
|
@@ -117,32 +140,58 @@ module BreakerMachines
|
|
|
117
140
|
end
|
|
118
141
|
|
|
119
142
|
def get_window_count(key, window_seconds)
|
|
143
|
+
warn_window_exceeds_retention(window_seconds)
|
|
144
|
+
count = bucket_count([window_seconds, @expires_in].min)
|
|
145
|
+
|
|
120
146
|
if @cache.respond_to?(:increment)
|
|
121
|
-
|
|
122
|
-
# Would need to implement bucketing similar to fallback
|
|
123
|
-
@cache.read(key) || 0
|
|
147
|
+
read_counters(bucket_keys(key, count)).sum(&:to_i)
|
|
124
148
|
else
|
|
125
|
-
# Bucket-based counting for accurate windows
|
|
126
149
|
buckets = @cache.read(key) || {}
|
|
127
|
-
|
|
150
|
+
now = current_bucket
|
|
151
|
+
count.times.sum { |offset| buckets[now - offset] || 0 }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
128
154
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
end
|
|
155
|
+
# Counters live for expires_in, so a longer window silently undercounts.
|
|
156
|
+
def warn_window_exceeds_retention(window_seconds)
|
|
157
|
+
return if window_seconds <= @expires_in
|
|
158
|
+
return unless @warned_windows.add?(window_seconds)
|
|
134
159
|
|
|
135
|
-
|
|
136
|
-
|
|
160
|
+
BreakerMachines.logger&.warn(
|
|
161
|
+
"[BreakerMachines] Cache storage keeps counts for #{@expires_in}s, so a #{window_seconds}s " \
|
|
162
|
+
"window only sees the last #{@expires_in}s. Set expires_in to at least the window."
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# read_multi is a single round trip; stores without it read each bucket.
|
|
167
|
+
def read_counters(keys)
|
|
168
|
+
return @cache.read_multi(*keys, raw: true).values if @cache.respond_to?(:read_multi)
|
|
169
|
+
|
|
170
|
+
keys.filter_map { |key| @cache.read(key) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def delete_counters(keys)
|
|
174
|
+
return @cache.delete_multi(keys) if @cache.respond_to?(:delete_multi)
|
|
175
|
+
|
|
176
|
+
keys.each { |key| @cache.delete(key) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def bucket_count(seconds)
|
|
180
|
+
(seconds.to_f / @bucket_seconds).ceil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def bucket_keys(key, count)
|
|
184
|
+
now = current_bucket
|
|
185
|
+
Array.new(count) { |offset| "#{key}:#{now - offset}" }
|
|
137
186
|
end
|
|
138
187
|
|
|
139
188
|
def prune_old_buckets(buckets)
|
|
140
|
-
cutoff = current_bucket -
|
|
141
|
-
buckets.delete_if { |
|
|
189
|
+
cutoff = current_bucket - bucket_count(@expires_in)
|
|
190
|
+
buckets.delete_if { |bucket, _| bucket < cutoff }
|
|
142
191
|
end
|
|
143
192
|
|
|
144
193
|
def current_bucket
|
|
145
|
-
Time.now.to_i
|
|
194
|
+
Time.now.to_i / @bucket_seconds
|
|
146
195
|
end
|
|
147
196
|
|
|
148
197
|
def status_key(circuit_name)
|
|
@@ -58,10 +58,12 @@ module BreakerMachines
|
|
|
58
58
|
timestamp: current_time
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
#
|
|
62
|
-
|
|
61
|
+
# Keep 5 minutes of history. Events are appended oldest first, so only
|
|
62
|
+
# scan once the oldest has aged out; scanning on every write made each
|
|
63
|
+
# write O(n) once more than 100 events were retained.
|
|
64
|
+
cutoff_time = current_time - 300
|
|
65
|
+
return unless events.size > 100 && events.first[:timestamp] < cutoff_time
|
|
63
66
|
|
|
64
|
-
cutoff_time = current_time - 300 # Keep 5 minutes of history
|
|
65
67
|
events.delete_if { |e| e[:timestamp] < cutoff_time }
|
|
66
68
|
end
|
|
67
69
|
|
data/lib/breaker_machines.rb
CHANGED
|
@@ -184,6 +184,35 @@ module BreakerMachines
|
|
|
184
184
|
def monotonic_time
|
|
185
185
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
186
186
|
end
|
|
187
|
+
|
|
188
|
+
# Converts a {monotonic_time} value to Unix epoch seconds. Each host's
|
|
189
|
+
# monotonic clock has its own origin, so shared storage must persist
|
|
190
|
+
# timestamps in epoch time for other hosts to read them.
|
|
191
|
+
#
|
|
192
|
+
# @param monotonic [Float] a value from {monotonic_time}
|
|
193
|
+
# @return [Float] Unix epoch seconds
|
|
194
|
+
def monotonic_to_epoch(monotonic)
|
|
195
|
+
monotonic + clock_offset
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Converts Unix epoch seconds to this host's {monotonic_time}.
|
|
199
|
+
#
|
|
200
|
+
# @param epoch [Float] Unix epoch seconds
|
|
201
|
+
# @return [Float] the matching {monotonic_time} value on this host
|
|
202
|
+
def epoch_to_monotonic(epoch)
|
|
203
|
+
epoch - clock_offset
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
# Wall clock minus monotonic clock. Memoized so that converting the same
|
|
209
|
+
# timestamp twice gives the same result; re-measured only when the wall
|
|
210
|
+
# clock has stepped by more than a second.
|
|
211
|
+
def clock_offset
|
|
212
|
+
offset = Process.clock_gettime(Process::CLOCK_REALTIME) - monotonic_time
|
|
213
|
+
@clock_offset = offset if @clock_offset.nil? || (offset - @clock_offset).abs > 1.0
|
|
214
|
+
@clock_offset
|
|
215
|
+
end
|
|
187
216
|
end
|
|
188
217
|
|
|
189
218
|
# Set up notifications on first use
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: breaker_machines
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.20.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -15,28 +15,28 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '8.
|
|
18
|
+
version: '8.1'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '8.
|
|
25
|
+
version: '8.1'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: chrono_machines
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '0.
|
|
32
|
+
version: '0.10'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '0.
|
|
39
|
+
version: '0.10'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: concurrent-ruby
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -85,14 +85,14 @@ dependencies:
|
|
|
85
85
|
requirements:
|
|
86
86
|
- - "~>"
|
|
87
87
|
- !ruby/object:Gem::Version
|
|
88
|
-
version: '
|
|
88
|
+
version: '6.0'
|
|
89
89
|
type: :development
|
|
90
90
|
prerelease: false
|
|
91
91
|
version_requirements: !ruby/object:Gem::Requirement
|
|
92
92
|
requirements:
|
|
93
93
|
- - "~>"
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
|
-
version: '
|
|
95
|
+
version: '6.0'
|
|
96
96
|
- !ruby/object:Gem::Dependency
|
|
97
97
|
name: rb_sys
|
|
98
98
|
requirement: !ruby/object:Gem::Requirement
|