ci-queue 0.82.0 → 0.83.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/Gemfile.lock +1 -1
- data/README.md +87 -0
- data/lib/ci/queue/class_resolver.rb +38 -0
- data/lib/ci/queue/configuration.rb +62 -1
- data/lib/ci/queue/file_loader.rb +101 -0
- data/lib/ci/queue/queue_entry.rb +56 -0
- data/lib/ci/queue/redis/_entry_helpers.lua +10 -0
- data/lib/ci/queue/redis/acknowledge.lua +10 -7
- data/lib/ci/queue/redis/base.rb +33 -7
- data/lib/ci/queue/redis/build_record.rb +12 -0
- data/lib/ci/queue/redis/heartbeat.lua +9 -4
- data/lib/ci/queue/redis/monitor.rb +19 -5
- data/lib/ci/queue/redis/requeue.lua +19 -11
- data/lib/ci/queue/redis/reserve.lua +47 -8
- data/lib/ci/queue/redis/reserve_lost.lua +5 -1
- data/lib/ci/queue/redis/supervisor.rb +3 -3
- data/lib/ci/queue/redis/worker.rb +216 -23
- data/lib/ci/queue/version.rb +1 -1
- data/lib/ci/queue.rb +27 -0
- data/lib/minitest/queue/junit_reporter.rb +2 -2
- data/lib/minitest/queue/lazy_entry_resolver.rb +55 -0
- data/lib/minitest/queue/lazy_test_discovery.rb +169 -0
- data/lib/minitest/queue/local_requeue_reporter.rb +11 -0
- data/lib/minitest/queue/order_reporter.rb +9 -2
- data/lib/minitest/queue/queue_population_strategy.rb +176 -0
- data/lib/minitest/queue/runner.rb +97 -22
- data/lib/minitest/queue/test_data.rb +14 -1
- data/lib/minitest/queue/worker_profile_reporter.rb +77 -0
- data/lib/minitest/queue.rb +271 -6
- metadata +9 -1
|
@@ -6,17 +6,20 @@ local zset_key = KEYS[4]
|
|
|
6
6
|
local worker_queue_key = KEYS[5]
|
|
7
7
|
local owners_key = KEYS[6]
|
|
8
8
|
local error_reports_key = KEYS[7]
|
|
9
|
+
local requeued_by_key = KEYS[8]
|
|
9
10
|
|
|
10
11
|
local max_requeues = tonumber(ARGV[1])
|
|
11
12
|
local global_max_requeues = tonumber(ARGV[2])
|
|
12
|
-
local
|
|
13
|
-
local
|
|
13
|
+
local entry = ARGV[3]
|
|
14
|
+
local test_id = ARGV[4]
|
|
15
|
+
local offset = ARGV[5]
|
|
16
|
+
local ttl = tonumber(ARGV[6])
|
|
14
17
|
|
|
15
|
-
if redis.call('hget', owners_key,
|
|
16
|
-
redis.call('hdel', owners_key,
|
|
18
|
+
if redis.call('hget', owners_key, entry) == worker_queue_key then
|
|
19
|
+
redis.call('hdel', owners_key, entry)
|
|
17
20
|
end
|
|
18
21
|
|
|
19
|
-
if redis.call('sismember', processed_key,
|
|
22
|
+
if redis.call('sismember', processed_key, test_id) == 1 then
|
|
20
23
|
return false
|
|
21
24
|
end
|
|
22
25
|
|
|
@@ -25,23 +28,28 @@ if global_requeues and global_requeues >= tonumber(global_max_requeues) then
|
|
|
25
28
|
return false
|
|
26
29
|
end
|
|
27
30
|
|
|
28
|
-
local requeues = tonumber(redis.call('hget', requeues_count_key,
|
|
31
|
+
local requeues = tonumber(redis.call('hget', requeues_count_key, test_id))
|
|
29
32
|
if requeues and requeues >= max_requeues then
|
|
30
33
|
return false
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
redis.call('hincrby', requeues_count_key, '___total___', 1)
|
|
34
|
-
redis.call('hincrby', requeues_count_key,
|
|
37
|
+
redis.call('hincrby', requeues_count_key, test_id, 1)
|
|
35
38
|
|
|
36
|
-
redis.call('hdel', error_reports_key,
|
|
39
|
+
redis.call('hdel', error_reports_key, test_id)
|
|
37
40
|
|
|
38
41
|
local pivot = redis.call('lrange', queue_key, -1 - offset, 0 - offset)[1]
|
|
39
42
|
if pivot then
|
|
40
|
-
redis.call('linsert', queue_key, 'BEFORE', pivot,
|
|
43
|
+
redis.call('linsert', queue_key, 'BEFORE', pivot, entry)
|
|
41
44
|
else
|
|
42
|
-
redis.call('lpush', queue_key,
|
|
45
|
+
redis.call('lpush', queue_key, entry)
|
|
43
46
|
end
|
|
44
47
|
|
|
45
|
-
redis.call('
|
|
48
|
+
redis.call('hset', requeued_by_key, entry, worker_queue_key)
|
|
49
|
+
if ttl and ttl > 0 then
|
|
50
|
+
redis.call('expire', requeued_by_key, ttl)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
redis.call('zrem', zset_key, entry)
|
|
46
54
|
|
|
47
55
|
return true
|
|
@@ -4,15 +4,54 @@ local zset_key = KEYS[2]
|
|
|
4
4
|
local processed_key = KEYS[3]
|
|
5
5
|
local worker_queue_key = KEYS[4]
|
|
6
6
|
local owners_key = KEYS[5]
|
|
7
|
+
local requeued_by_key = KEYS[6]
|
|
8
|
+
local workers_key = KEYS[7]
|
|
7
9
|
|
|
8
10
|
local current_time = ARGV[1]
|
|
11
|
+
local defer_offset = tonumber(ARGV[2]) or 0
|
|
12
|
+
local max_skip_attempts = 4
|
|
9
13
|
|
|
10
|
-
local
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return nil
|
|
14
|
+
local function insert_with_offset(test)
|
|
15
|
+
local pivot = redis.call('lrange', queue_key, -1 - defer_offset, 0 - defer_offset)[1]
|
|
16
|
+
if pivot then
|
|
17
|
+
redis.call('linsert', queue_key, 'BEFORE', pivot, test)
|
|
18
|
+
else
|
|
19
|
+
redis.call('lpush', queue_key, test)
|
|
20
|
+
end
|
|
18
21
|
end
|
|
22
|
+
|
|
23
|
+
for attempt = 1, max_skip_attempts do
|
|
24
|
+
local test = redis.call('rpop', queue_key)
|
|
25
|
+
if not test then
|
|
26
|
+
return nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
local requeued_by = redis.call('hget', requeued_by_key, test)
|
|
30
|
+
if requeued_by == worker_queue_key then
|
|
31
|
+
-- If this build only has one worker, allow immediate self-pickup.
|
|
32
|
+
if redis.call('scard', workers_key) <= 1 then
|
|
33
|
+
redis.call('hdel', requeued_by_key, test)
|
|
34
|
+
redis.call('zadd', zset_key, current_time, test)
|
|
35
|
+
redis.call('lpush', worker_queue_key, test)
|
|
36
|
+
redis.call('hset', owners_key, test, worker_queue_key)
|
|
37
|
+
return test
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
insert_with_offset(test)
|
|
41
|
+
|
|
42
|
+
-- If this worker only finds its own requeued tests, defer once by returning nil,
|
|
43
|
+
-- then allow pickup on a subsequent reserve attempt.
|
|
44
|
+
if attempt == max_skip_attempts then
|
|
45
|
+
redis.call('hdel', requeued_by_key, test)
|
|
46
|
+
return nil
|
|
47
|
+
end
|
|
48
|
+
else
|
|
49
|
+
redis.call('hdel', requeued_by_key, test)
|
|
50
|
+
redis.call('zadd', zset_key, current_time, test)
|
|
51
|
+
redis.call('lpush', worker_queue_key, test)
|
|
52
|
+
redis.call('hset', owners_key, test, worker_queue_key)
|
|
53
|
+
return test
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
return nil
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
-- AUTOGENERATED FILE DO NOT EDIT DIRECTLY
|
|
2
|
+
-- @include _entry_helpers
|
|
3
|
+
|
|
2
4
|
local zset_key = KEYS[1]
|
|
3
5
|
local processed_key = KEYS[2]
|
|
4
6
|
local worker_queue_key = KEYS[3]
|
|
@@ -6,10 +8,12 @@ local owners_key = KEYS[4]
|
|
|
6
8
|
|
|
7
9
|
local current_time = ARGV[1]
|
|
8
10
|
local timeout = ARGV[2]
|
|
11
|
+
local entry_delimiter = ARGV[3]
|
|
9
12
|
|
|
10
13
|
local lost_tests = redis.call('zrangebyscore', zset_key, 0, current_time - timeout)
|
|
11
14
|
for _, test in ipairs(lost_tests) do
|
|
12
|
-
|
|
15
|
+
local test_id = test_id_from_entry(test, entry_delimiter)
|
|
16
|
+
if redis.call('sismember', processed_key, test_id) == 0 then
|
|
13
17
|
redis.call('zadd', zset_key, current_time, test)
|
|
14
18
|
redis.call('lpush', worker_queue_key, test)
|
|
15
19
|
redis.call('hset', owners_key, test, worker_queue_key) -- Take ownership
|
|
@@ -9,7 +9,7 @@ module CI
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def total
|
|
12
|
-
wait_for_master(timeout: config.queue_init_timeout)
|
|
12
|
+
wait_for_master(timeout: config.queue_init_timeout, allow_streaming: true)
|
|
13
13
|
redis.get(key('total')).to_i
|
|
14
14
|
end
|
|
15
15
|
|
|
@@ -19,7 +19,7 @@ module CI
|
|
|
19
19
|
|
|
20
20
|
def wait_for_workers
|
|
21
21
|
duration = measure do
|
|
22
|
-
wait_for_master(timeout: config.queue_init_timeout)
|
|
22
|
+
wait_for_master(timeout: config.queue_init_timeout, allow_streaming: true)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
yield if block_given?
|
|
@@ -30,7 +30,7 @@ module CI
|
|
|
30
30
|
@time_left -= 1
|
|
31
31
|
sleep 1
|
|
32
32
|
|
|
33
|
-
if active_workers?
|
|
33
|
+
if active_workers? || streaming?
|
|
34
34
|
@time_left_with_no_workers = config.inactive_workers_timeout
|
|
35
35
|
else
|
|
36
36
|
@time_left_with_no_workers -= 1
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'ci/queue/static'
|
|
3
3
|
require 'concurrent/set'
|
|
4
|
+
require 'concurrent/map'
|
|
4
5
|
|
|
5
6
|
module CI
|
|
6
7
|
module Queue
|
|
@@ -13,11 +14,13 @@ module CI
|
|
|
13
14
|
self.max_sleep_time = 2
|
|
14
15
|
|
|
15
16
|
class Worker < Base
|
|
16
|
-
|
|
17
|
+
attr_accessor :entry_resolver
|
|
18
|
+
attr_reader :first_reserve_at
|
|
17
19
|
|
|
18
20
|
def initialize(redis, config)
|
|
19
21
|
@reserved_tests = Concurrent::Set.new
|
|
20
22
|
@shutdown_required = false
|
|
23
|
+
@first_reserve_at = nil
|
|
21
24
|
super(redis, config)
|
|
22
25
|
end
|
|
23
26
|
|
|
@@ -27,15 +30,65 @@ module CI
|
|
|
27
30
|
|
|
28
31
|
def populate(tests, random: Random.new)
|
|
29
32
|
@index = tests.map { |t| [t.id, t] }.to_h
|
|
30
|
-
|
|
31
|
-
push(
|
|
33
|
+
entries = Queue.shuffle(tests, random).map { |test| queue_entry_for(test) }
|
|
34
|
+
push(entries)
|
|
32
35
|
self
|
|
33
36
|
end
|
|
34
37
|
|
|
38
|
+
def stream_populate(tests, random: Random.new, batch_size: 10_000)
|
|
39
|
+
batch_size = batch_size.to_i
|
|
40
|
+
batch_size = 1 if batch_size < 1
|
|
41
|
+
|
|
42
|
+
value = key('setup', worker_id)
|
|
43
|
+
_, status = redis.pipelined do |pipeline|
|
|
44
|
+
pipeline.set(key('master-status'), value, nx: true)
|
|
45
|
+
pipeline.get(key('master-status'))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if @master = (value == status)
|
|
49
|
+
@total = 0
|
|
50
|
+
puts "Worker elected as leader, streaming tests to the queue."
|
|
51
|
+
|
|
52
|
+
duration = measure do
|
|
53
|
+
start_streaming!
|
|
54
|
+
buffer = []
|
|
55
|
+
|
|
56
|
+
tests.each do |test|
|
|
57
|
+
buffer << test
|
|
58
|
+
|
|
59
|
+
if buffer.size >= batch_size
|
|
60
|
+
push_batch(buffer, random)
|
|
61
|
+
buffer.clear
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
push_batch(buffer, random) unless buffer.empty?
|
|
66
|
+
finalize_streaming
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
puts "Streamed #{@total} tests in #{duration.round(2)}s."
|
|
70
|
+
$stdout.flush
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
register
|
|
74
|
+
redis.expire(key('workers'), config.redis_ttl)
|
|
75
|
+
self
|
|
76
|
+
rescue *CONNECTION_ERRORS
|
|
77
|
+
raise if @master
|
|
78
|
+
end
|
|
79
|
+
|
|
35
80
|
def populated?
|
|
36
81
|
!!defined?(@index)
|
|
37
82
|
end
|
|
38
83
|
|
|
84
|
+
def total
|
|
85
|
+
return @total if defined?(@total) && @total
|
|
86
|
+
|
|
87
|
+
redis.get(key('total')).to_i
|
|
88
|
+
rescue *CONNECTION_ERRORS
|
|
89
|
+
@total || 0
|
|
90
|
+
end
|
|
91
|
+
|
|
39
92
|
def shutdown!
|
|
40
93
|
@shutdown_required = true
|
|
41
94
|
end
|
|
@@ -51,13 +104,18 @@ module CI
|
|
|
51
104
|
DEFAULT_SLEEP_SECONDS = 0.5
|
|
52
105
|
|
|
53
106
|
def poll
|
|
54
|
-
wait_for_master
|
|
107
|
+
wait_for_master(timeout: config.queue_init_timeout, allow_streaming: true)
|
|
55
108
|
attempt = 0
|
|
56
109
|
until shutdown_required? || config.circuit_breakers.any?(&:open?) || exhausted? || max_test_failed?
|
|
57
|
-
if
|
|
110
|
+
if entry = reserve
|
|
58
111
|
attempt = 0
|
|
59
|
-
yield
|
|
112
|
+
yield resolve_entry(entry)
|
|
60
113
|
else
|
|
114
|
+
if still_streaming?
|
|
115
|
+
raise LostMaster, "Streaming stalled for more than #{config.lazy_load_streaming_timeout}s" if streaming_stale?
|
|
116
|
+
sleep 0.1
|
|
117
|
+
next
|
|
118
|
+
end
|
|
61
119
|
# Adding exponential backoff to avoid hammering Redis
|
|
62
120
|
# we just stay online here in case a test gets retried or times out so we can afford to wait
|
|
63
121
|
sleep_time = [DEFAULT_SLEEP_SECONDS * (2 ** attempt), Redis.max_sleep_time].min
|
|
@@ -89,6 +147,7 @@ module CI
|
|
|
89
147
|
def retry_queue
|
|
90
148
|
failures = build.failed_tests.to_set
|
|
91
149
|
log = redis.lrange(key('worker', worker_id, 'queue'), 0, -1)
|
|
150
|
+
log = log.map { |entry| queue_entry_test_id(entry) }
|
|
92
151
|
log.select! { |id| failures.include?(id) }
|
|
93
152
|
log.uniq!
|
|
94
153
|
log.reverse!
|
|
@@ -103,23 +162,38 @@ module CI
|
|
|
103
162
|
@build ||= CI::Queue::Redis::BuildRecord.new(self, redis, config)
|
|
104
163
|
end
|
|
105
164
|
|
|
165
|
+
def file_loader
|
|
166
|
+
@file_loader ||= CI::Queue::FileLoader.new
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def worker_queue_length
|
|
170
|
+
redis.llen(key('worker', worker_id, 'queue'))
|
|
171
|
+
rescue *CONNECTION_ERRORS
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
106
175
|
def report_worker_error(error)
|
|
107
176
|
build.report_worker_error(error)
|
|
108
177
|
end
|
|
109
178
|
|
|
110
179
|
def acknowledge(test_key, error: nil, pipeline: redis)
|
|
111
|
-
|
|
180
|
+
test_id = normalize_test_id(test_key)
|
|
181
|
+
assert_reserved!(test_id)
|
|
182
|
+
entry = reserved_entries.fetch(test_id, queue_entry_for(test_key))
|
|
183
|
+
unreserve_entry(test_id)
|
|
112
184
|
eval_script(
|
|
113
185
|
:acknowledge,
|
|
114
|
-
keys: [key('running'), key('processed'), key('owners'), key('error-reports')],
|
|
115
|
-
argv: [
|
|
186
|
+
keys: [key('running'), key('processed'), key('owners'), key('error-reports'), key('requeued-by')],
|
|
187
|
+
argv: [entry, test_id, error.to_s, config.redis_ttl],
|
|
116
188
|
pipeline: pipeline,
|
|
117
189
|
) == 1
|
|
118
190
|
end
|
|
119
191
|
|
|
120
192
|
def requeue(test, offset: Redis.requeue_offset)
|
|
121
|
-
|
|
122
|
-
|
|
193
|
+
test_id = normalize_test_id(test)
|
|
194
|
+
assert_reserved!(test_id)
|
|
195
|
+
entry = reserved_entries.fetch(test_id, queue_entry_for(test))
|
|
196
|
+
unreserve_entry(test_id)
|
|
123
197
|
global_max_requeues = config.global_max_requeues(total)
|
|
124
198
|
|
|
125
199
|
requeued = config.max_requeues > 0 && global_max_requeues > 0 && eval_script(
|
|
@@ -132,11 +206,16 @@ module CI
|
|
|
132
206
|
key('worker', worker_id, 'queue'),
|
|
133
207
|
key('owners'),
|
|
134
208
|
key('error-reports'),
|
|
209
|
+
key('requeued-by'),
|
|
135
210
|
],
|
|
136
|
-
argv: [config.max_requeues, global_max_requeues,
|
|
211
|
+
argv: [config.max_requeues, global_max_requeues, entry, test_id, offset, config.redis_ttl],
|
|
137
212
|
) == 1
|
|
138
213
|
|
|
139
|
-
|
|
214
|
+
unless requeued
|
|
215
|
+
reserved_tests << test_id
|
|
216
|
+
reserved_entries[test_id] = entry
|
|
217
|
+
reserved_entry_ids[entry] = test_id
|
|
218
|
+
end
|
|
140
219
|
requeued
|
|
141
220
|
end
|
|
142
221
|
|
|
@@ -157,19 +236,131 @@ module CI
|
|
|
157
236
|
@reserved_tests ||= Concurrent::Set.new
|
|
158
237
|
end
|
|
159
238
|
|
|
239
|
+
def reserved_entries
|
|
240
|
+
@reserved_entries ||= Concurrent::Map.new
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def reserved_entry_ids
|
|
244
|
+
@reserved_entry_ids ||= Concurrent::Map.new
|
|
245
|
+
end
|
|
246
|
+
|
|
160
247
|
def worker_id
|
|
161
248
|
config.worker_id
|
|
162
249
|
end
|
|
163
250
|
|
|
164
|
-
def
|
|
165
|
-
unless reserved_tests.
|
|
166
|
-
raise ReservationError, "Acknowledged #{
|
|
251
|
+
def assert_reserved!(test_id)
|
|
252
|
+
unless reserved_tests.include?(test_id)
|
|
253
|
+
raise ReservationError, "Acknowledged #{test_id.inspect} but only #{reserved_tests.map(&:inspect).join(", ")} reserved"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def reserve_entry(entry)
|
|
258
|
+
test_id = queue_entry_test_id(entry)
|
|
259
|
+
reserved_tests << test_id
|
|
260
|
+
reserved_entries[test_id] = entry
|
|
261
|
+
reserved_entry_ids[entry] = test_id
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def unreserve_entry(test_id)
|
|
265
|
+
entry = reserved_entries.delete(test_id)
|
|
266
|
+
reserved_tests.delete(test_id)
|
|
267
|
+
reserved_entry_ids.delete(entry) if entry
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def normalize_test_id(test_key)
|
|
271
|
+
key = test_key.respond_to?(:id) ? test_key.id : test_key
|
|
272
|
+
if key.is_a?(String)
|
|
273
|
+
cached = reserved_entry_ids[key]
|
|
274
|
+
return cached if cached
|
|
275
|
+
end
|
|
276
|
+
queue_entry_test_id(key)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def queue_entry_test_id(entry)
|
|
280
|
+
CI::Queue::QueueEntry.test_id(entry)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def queue_entry_for(test)
|
|
284
|
+
return test.queue_entry if test.respond_to?(:queue_entry)
|
|
285
|
+
return test.id if test.respond_to?(:id)
|
|
286
|
+
|
|
287
|
+
test
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def resolve_entry(entry)
|
|
291
|
+
test_id = reserved_entry_ids[entry] || queue_entry_test_id(entry)
|
|
292
|
+
if populated?
|
|
293
|
+
return index[test_id] if index.key?(test_id)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
return entry_resolver.call(entry) if entry_resolver
|
|
297
|
+
|
|
298
|
+
entry
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def still_streaming?
|
|
302
|
+
master_status == 'streaming'
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def streaming_stale?
|
|
306
|
+
timeout = config.lazy_load_streaming_timeout.to_i
|
|
307
|
+
updated_at = redis.get(key('streaming-updated-at'))
|
|
308
|
+
return true unless updated_at
|
|
309
|
+
|
|
310
|
+
(CI::Queue.time_now.to_f - updated_at.to_f) > timeout
|
|
311
|
+
rescue *CONNECTION_ERRORS
|
|
312
|
+
false
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def start_streaming!
|
|
316
|
+
timeout = config.lazy_load_streaming_timeout.to_i
|
|
317
|
+
with_redis_timeout(5) do
|
|
318
|
+
redis.multi do |transaction|
|
|
319
|
+
transaction.set(key('total'), 0)
|
|
320
|
+
transaction.set(key('master-status'), 'streaming')
|
|
321
|
+
transaction.set(key('streaming-updated-at'), CI::Queue.time_now.to_f)
|
|
322
|
+
transaction.expire(key('streaming-updated-at'), timeout)
|
|
323
|
+
transaction.expire(key('queue'), config.redis_ttl)
|
|
324
|
+
transaction.expire(key('total'), config.redis_ttl)
|
|
325
|
+
transaction.expire(key('master-status'), config.redis_ttl)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def push_batch(tests, random)
|
|
331
|
+
# Use plain shuffle instead of Queue.shuffle — the custom shuffler expects
|
|
332
|
+
# test objects with .id, but streaming entries are pre-formatted strings.
|
|
333
|
+
entries = tests.shuffle(random: random).map { |test| queue_entry_for(test) }
|
|
334
|
+
return if entries.empty?
|
|
335
|
+
|
|
336
|
+
@total += entries.size
|
|
337
|
+
timeout = config.lazy_load_streaming_timeout.to_i
|
|
338
|
+
redis.multi do |transaction|
|
|
339
|
+
transaction.lpush(key('queue'), entries)
|
|
340
|
+
transaction.incrby(key('total'), entries.size)
|
|
341
|
+
transaction.set(key('master-status'), 'streaming')
|
|
342
|
+
transaction.set(key('streaming-updated-at'), CI::Queue.time_now.to_f)
|
|
343
|
+
transaction.expire(key('streaming-updated-at'), timeout)
|
|
344
|
+
transaction.expire(key('queue'), config.redis_ttl)
|
|
345
|
+
transaction.expire(key('total'), config.redis_ttl)
|
|
346
|
+
transaction.expire(key('master-status'), config.redis_ttl)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def finalize_streaming
|
|
351
|
+
redis.multi do |transaction|
|
|
352
|
+
transaction.set(key('master-status'), 'ready')
|
|
353
|
+
transaction.expire(key('master-status'), config.redis_ttl)
|
|
354
|
+
transaction.del(key('streaming-updated-at'))
|
|
167
355
|
end
|
|
168
356
|
end
|
|
169
357
|
|
|
170
358
|
def reserve
|
|
171
|
-
(try_to_reserve_lost_test || try_to_reserve_test).tap do |
|
|
172
|
-
|
|
359
|
+
(try_to_reserve_lost_test || try_to_reserve_test).tap do |entry|
|
|
360
|
+
if entry
|
|
361
|
+
@first_reserve_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
362
|
+
reserve_entry(entry)
|
|
363
|
+
end
|
|
173
364
|
end
|
|
174
365
|
end
|
|
175
366
|
|
|
@@ -182,8 +373,10 @@ module CI
|
|
|
182
373
|
key('processed'),
|
|
183
374
|
key('worker', worker_id, 'queue'),
|
|
184
375
|
key('owners'),
|
|
376
|
+
key('requeued-by'),
|
|
377
|
+
key('workers'),
|
|
185
378
|
],
|
|
186
|
-
argv: [CI::Queue.time_now.to_f],
|
|
379
|
+
argv: [CI::Queue.time_now.to_f, Redis.requeue_offset],
|
|
187
380
|
)
|
|
188
381
|
end
|
|
189
382
|
|
|
@@ -198,7 +391,7 @@ module CI
|
|
|
198
391
|
key('worker', worker_id, 'queue'),
|
|
199
392
|
key('owners'),
|
|
200
393
|
],
|
|
201
|
-
argv: [CI::Queue.time_now.to_f, timeout],
|
|
394
|
+
argv: [CI::Queue.time_now.to_f, timeout, CI::Queue::QueueEntry::DELIMITER],
|
|
202
395
|
)
|
|
203
396
|
|
|
204
397
|
if lost_test
|
|
@@ -208,8 +401,8 @@ module CI
|
|
|
208
401
|
lost_test
|
|
209
402
|
end
|
|
210
403
|
|
|
211
|
-
def push(
|
|
212
|
-
@total =
|
|
404
|
+
def push(entries)
|
|
405
|
+
@total = entries.size
|
|
213
406
|
|
|
214
407
|
# We set a unique value (worker_id) and read it back to make "SET if Not eXists" idempotent in case of a retry.
|
|
215
408
|
value = key('setup', worker_id)
|
|
@@ -227,7 +420,7 @@ module CI
|
|
|
227
420
|
with_redis_timeout(5) do
|
|
228
421
|
redis.without_reconnect do
|
|
229
422
|
redis.multi do |transaction|
|
|
230
|
-
transaction.lpush(key('queue'),
|
|
423
|
+
transaction.lpush(key('queue'), entries) unless entries.empty?
|
|
231
424
|
transaction.set(key('total'), @total)
|
|
232
425
|
transaction.set(key('master-status'), 'ready')
|
|
233
426
|
|
data/lib/ci/queue/version.rb
CHANGED
data/lib/ci/queue.rb
CHANGED
|
@@ -14,6 +14,9 @@ require 'ci/queue/static'
|
|
|
14
14
|
require 'ci/queue/file'
|
|
15
15
|
require 'ci/queue/grind'
|
|
16
16
|
require 'ci/queue/bisect'
|
|
17
|
+
require 'ci/queue/queue_entry'
|
|
18
|
+
require 'ci/queue/class_resolver'
|
|
19
|
+
require 'ci/queue/file_loader'
|
|
17
20
|
|
|
18
21
|
module CI
|
|
19
22
|
module Queue
|
|
@@ -22,6 +25,18 @@ module CI
|
|
|
22
25
|
attr_accessor :shuffler, :requeueable
|
|
23
26
|
|
|
24
27
|
Error = Class.new(StandardError)
|
|
28
|
+
ClassNotFoundError = Class.new(Error)
|
|
29
|
+
|
|
30
|
+
class FileLoadError < Error
|
|
31
|
+
attr_reader :file_path, :original_error
|
|
32
|
+
|
|
33
|
+
def initialize(file_path, original_error)
|
|
34
|
+
@file_path = file_path
|
|
35
|
+
@original_error = original_error
|
|
36
|
+
super("Failed to load #{file_path}: #{original_error.class}: #{original_error.message}")
|
|
37
|
+
set_backtrace(original_error.backtrace)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
25
40
|
|
|
26
41
|
module Warnings
|
|
27
42
|
RESERVED_LOST_TEST = :RESERVED_LOST_TEST
|
|
@@ -48,6 +63,17 @@ module CI
|
|
|
48
63
|
end
|
|
49
64
|
end
|
|
50
65
|
|
|
66
|
+
def debug?
|
|
67
|
+
return @debug if defined?(@debug)
|
|
68
|
+
|
|
69
|
+
value = ENV['CI_QUEUE_DEBUG']
|
|
70
|
+
@debug = !!(value && !value.strip.empty? && !%w[0 false].include?(value.strip.downcase))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reset_debug!
|
|
74
|
+
remove_instance_variable(:@debug) if defined?(@debug)
|
|
75
|
+
end
|
|
76
|
+
|
|
51
77
|
def from_uri(url, config)
|
|
52
78
|
uri = URI(url)
|
|
53
79
|
implementation = case uri.scheme
|
|
@@ -65,3 +91,4 @@ module CI
|
|
|
65
91
|
end
|
|
66
92
|
end
|
|
67
93
|
end
|
|
94
|
+
|
|
@@ -136,9 +136,9 @@ module Minitest
|
|
|
136
136
|
result[:time] = 0
|
|
137
137
|
tests.each do |test|
|
|
138
138
|
result[:"#{result(test)}_count"] += 1
|
|
139
|
-
result[:assertion_count] += test.assertions
|
|
139
|
+
result[:assertion_count] += test.assertions || 0
|
|
140
140
|
result[:test_count] += 1
|
|
141
|
-
result[:time] += test.time
|
|
141
|
+
result[:time] += test.time || 0
|
|
142
142
|
end
|
|
143
143
|
result
|
|
144
144
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Minitest
|
|
4
|
+
module Queue
|
|
5
|
+
class LazyEntryResolver
|
|
6
|
+
def initialize(loader:, resolver:)
|
|
7
|
+
@loader = loader
|
|
8
|
+
@resolver = resolver
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(entry)
|
|
12
|
+
parsed = CI::Queue::QueueEntry.parse(entry)
|
|
13
|
+
class_name, method_name = parsed.fetch(:test_id).split('#', 2)
|
|
14
|
+
if CI::Queue::QueueEntry.load_error_payload?(parsed[:file_path])
|
|
15
|
+
payload = CI::Queue::QueueEntry.decode_load_error(parsed[:file_path])
|
|
16
|
+
if payload
|
|
17
|
+
error = StandardError.new("#{payload['error_class']}: #{payload['error_message']}")
|
|
18
|
+
error.set_backtrace(payload['backtrace']) if payload['backtrace']
|
|
19
|
+
load_error = CI::Queue::FileLoadError.new(payload['file_path'], error)
|
|
20
|
+
return Minitest::Queue::LazySingleExample.new(
|
|
21
|
+
class_name,
|
|
22
|
+
method_name,
|
|
23
|
+
payload['file_path'],
|
|
24
|
+
loader: @loader,
|
|
25
|
+
resolver: @resolver,
|
|
26
|
+
load_error: load_error,
|
|
27
|
+
queue_entry: entry,
|
|
28
|
+
)
|
|
29
|
+
else
|
|
30
|
+
error = StandardError.new("Corrupt load error payload for #{class_name}##{method_name}")
|
|
31
|
+
load_error = CI::Queue::FileLoadError.new("unknown", error)
|
|
32
|
+
return Minitest::Queue::LazySingleExample.new(
|
|
33
|
+
class_name,
|
|
34
|
+
method_name,
|
|
35
|
+
nil,
|
|
36
|
+
loader: @loader,
|
|
37
|
+
resolver: @resolver,
|
|
38
|
+
load_error: load_error,
|
|
39
|
+
queue_entry: entry,
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Minitest::Queue::LazySingleExample.new(
|
|
45
|
+
class_name,
|
|
46
|
+
method_name,
|
|
47
|
+
parsed[:file_path],
|
|
48
|
+
loader: @loader,
|
|
49
|
+
resolver: @resolver,
|
|
50
|
+
queue_entry: entry,
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|