rspec-hopper 0.1.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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +537 -0
- data/Rakefile +10 -0
- data/docs/DESIGN.md +386 -0
- data/exe/rspec-hopper +6 -0
- data/lib/rspec/hopper/attempt_log.rb +133 -0
- data/lib/rspec/hopper/ci_env.rb +87 -0
- data/lib/rspec/hopper/cli/formatter_args.rb +129 -0
- data/lib/rspec/hopper/cli/report.rb +117 -0
- data/lib/rspec/hopper/cli/work/parser.rb +166 -0
- data/lib/rspec/hopper/cli/work.rb +59 -0
- data/lib/rspec/hopper/cli.rb +61 -0
- data/lib/rspec/hopper/config.rb +48 -0
- data/lib/rspec/hopper/errors.rb +88 -0
- data/lib/rspec/hopper/example_reset.rb +41 -0
- data/lib/rspec/hopper/fingerprint.rb +185 -0
- data/lib/rspec/hopper/keys.rb +38 -0
- data/lib/rspec/hopper/manifest.rb +113 -0
- data/lib/rspec/hopper/queue/redis_streams/lua/init.lua +94 -0
- data/lib/rspec/hopper/queue/redis_streams/lua/transition.lua +476 -0
- data/lib/rspec/hopper/queue/redis_streams.rb +307 -0
- data/lib/rspec/hopper/queue.rb +24 -0
- data/lib/rspec/hopper/report.rb +286 -0
- data/lib/rspec/hopper/reservation.rb +18 -0
- data/lib/rspec/hopper/supervisor.rb +196 -0
- data/lib/rspec/hopper/unit.rb +15 -0
- data/lib/rspec/hopper/version.rb +7 -0
- data/lib/rspec/hopper/worker/buffering_reporter.rb +51 -0
- data/lib/rspec/hopper/worker/heartbeat.rb +178 -0
- data/lib/rspec/hopper/worker/requeue_policy.rb +86 -0
- data/lib/rspec/hopper/worker/runner.rb +28 -0
- data/lib/rspec/hopper/worker/suite.rb +205 -0
- data/lib/rspec/hopper/worker.rb +299 -0
- data/lib/rspec/hopper.rb +65 -0
- metadata +137 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "redis"
|
|
6
|
+
|
|
7
|
+
module RSpec
|
|
8
|
+
module Hopper
|
|
9
|
+
module Queue
|
|
10
|
+
# The one queue adapter: Redis Streams with a consumer group per unit
|
|
11
|
+
# stream, two Lua scripts (initialization and state transition), and a
|
|
12
|
+
# four-part reservation handle that fences every mutation of an in-flight
|
|
13
|
+
# entry. See docs/DESIGN.md for the contract this implements.
|
|
14
|
+
#
|
|
15
|
+
# The adapter never opens a connection; the caller passes a `Redis`.
|
|
16
|
+
class RedisStreams
|
|
17
|
+
LUA_DIR = File.expand_path("redis_streams/lua", __dir__)
|
|
18
|
+
SCRIPTS = {
|
|
19
|
+
init: File.read(File.join(LUA_DIR, "init.lua")),
|
|
20
|
+
transition: File.read(File.join(LUA_DIR, "transition.lua"))
|
|
21
|
+
}.freeze
|
|
22
|
+
LEASE_SECONDS = 60
|
|
23
|
+
INIT_ERRORS = {
|
|
24
|
+
"LEASE_LOST" => LeaseLost,
|
|
25
|
+
"ALREADY_INITIALIZED" => PreviouslyInitialized,
|
|
26
|
+
"ALREADY_READY" => AlreadyInitialized
|
|
27
|
+
}.freeze
|
|
28
|
+
BUDGET_FIELDS = %i[max_requeues requeue_tolerance max_reclaims timeout ttl].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :redis, :build_id, :keyset, :ttl, :tombstone_ttl, :timeout,
|
|
31
|
+
:max_requeues, :requeue_tolerance, :max_reclaims
|
|
32
|
+
|
|
33
|
+
# Times are seconds; the adapter converts to milliseconds for Redis.
|
|
34
|
+
def initialize(redis:, build_id:, ttl: Config::WORK_DEFAULTS[:ttl],
|
|
35
|
+
tombstone_ttl: Config::WORK_DEFAULTS[:tombstone_ttl],
|
|
36
|
+
timeout: Config::WORK_DEFAULTS[:timeout],
|
|
37
|
+
max_requeues: Config::WORK_DEFAULTS[:max_requeues],
|
|
38
|
+
requeue_tolerance: Config::WORK_DEFAULTS[:requeue_tolerance],
|
|
39
|
+
max_reclaims: Config::WORK_DEFAULTS[:max_reclaims])
|
|
40
|
+
@redis = redis
|
|
41
|
+
@build_id = build_id
|
|
42
|
+
@keyset = Keys.new(build_id)
|
|
43
|
+
@ttl = ttl
|
|
44
|
+
@tombstone_ttl = tombstone_ttl
|
|
45
|
+
@timeout = timeout
|
|
46
|
+
@max_requeues = max_requeues
|
|
47
|
+
@requeue_tolerance = requeue_tolerance
|
|
48
|
+
@max_reclaims = max_reclaims
|
|
49
|
+
@shas = {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# -- build lifecycle ---------------------------------------------------
|
|
53
|
+
|
|
54
|
+
# One MULTI: meta and the tombstone read together so a caller can tell
|
|
55
|
+
# "never initialized" from "initialized, state gone".
|
|
56
|
+
def status
|
|
57
|
+
meta, tombstone = redis.multi do |m|
|
|
58
|
+
m.hgetall(keyset.meta)
|
|
59
|
+
m.exists(keyset.exists)
|
|
60
|
+
end
|
|
61
|
+
meta = nil if meta.empty?
|
|
62
|
+
Status.new(state: meta && meta["state"], tombstone: tombstone.positive?, meta: meta)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def acquire_leader(worker_id)
|
|
66
|
+
token = "#{worker_id}:#{SecureRandom.hex(8)}"
|
|
67
|
+
redis.set(keyset.leader, token, nx: true, ex: LEASE_SECONDS) ? token : nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def initialize_build(token:, manifest:, unit_ids:)
|
|
71
|
+
ids = unit_ids.map { |u| u.respond_to?(:id) ? u.id : u }
|
|
72
|
+
init_script("success", token, manifest, JSON.generate(ids))
|
|
73
|
+
:ready
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def fail_initialization(token:, manifest:)
|
|
77
|
+
init_script("failure", token, manifest, "[]")
|
|
78
|
+
:init_failed
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def manifest
|
|
82
|
+
meta = status.meta
|
|
83
|
+
meta && Manifest.from_meta(meta)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# -- reservation -------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
# Atomically claims one entry idle past `timeout` (priority stream
|
|
89
|
+
# first) and records the reclaim. Returns nil when nothing was claimed
|
|
90
|
+
# or when the script finalized the unit as reclaim_budget_exhausted.
|
|
91
|
+
def reclaim_lost(worker_id)
|
|
92
|
+
reply = check!(transition("reclaim", worker_id, ms(timeout), max_reclaims), :reclaim, nil)
|
|
93
|
+
return nil unless reply.first == "OK"
|
|
94
|
+
|
|
95
|
+
_, stream, entry_id, unit_id, unit_type, delivery_count, retry_index, reclaim_count = reply
|
|
96
|
+
Reservation.new(unit_id: unit_id, unit_type: unit_type, stream: stream, entry_id: entry_id,
|
|
97
|
+
consumer: worker_id, delivery_count: delivery_count,
|
|
98
|
+
retry_index: retry_index, reclaim_count: reclaim_count)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Priority stream without blocking, then units with BLOCK, then delivery
|
|
102
|
+
# accounting. Returns nil on an empty queue or when the entry was
|
|
103
|
+
# reclaimed between the read and the accounting.
|
|
104
|
+
def reserve(worker_id, block_ms: 1000)
|
|
105
|
+
stream, entry_id, fields = read_group(worker_id, "units:priority", nil) ||
|
|
106
|
+
read_group(worker_id, "units", block_ms)
|
|
107
|
+
return nil unless entry_id
|
|
108
|
+
|
|
109
|
+
reply = transition("delivery_accounting", worker_id, stream, entry_id, fields["id"])
|
|
110
|
+
return nil if reply.first == "STALE"
|
|
111
|
+
|
|
112
|
+
check!(reply, :delivery_accounting, fields["id"])
|
|
113
|
+
Reservation.new(unit_id: fields["id"], unit_type: fields["type"], stream: stream, entry_id: entry_id,
|
|
114
|
+
consumer: worker_id, delivery_count: reply[1],
|
|
115
|
+
retry_index: reply[2], reclaim_count: reply[3])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# -- fenced mutations --------------------------------------------------
|
|
119
|
+
# The protocol says these return `true` on success and raise otherwise.
|
|
120
|
+
# rubocop:disable Naming/PredicateMethod
|
|
121
|
+
|
|
122
|
+
def heartbeat(reservation)
|
|
123
|
+
fenced(:heartbeat, reservation, reservation.unit_id)
|
|
124
|
+
true
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def finalize(reservation, outcome:, duration_ms:, reason: nil, errors: nil)
|
|
128
|
+
outcome = outcome.to_sym
|
|
129
|
+
raise ArgumentError, "outcome must be one of #{OUTCOMES.join(", ")}" unless OUTCOMES.include?(outcome)
|
|
130
|
+
raise ArgumentError, "reason is required when outcome is failed" if outcome == :failed && reason.nil?
|
|
131
|
+
raise ArgumentError, "unknown reason #{reason}" if reason && !REASONS.include?(reason.to_s)
|
|
132
|
+
|
|
133
|
+
fenced(:finalize, reservation, reservation.unit_id, outcome.to_s, duration_ms.to_i,
|
|
134
|
+
reason.to_s, errors_json(errors))
|
|
135
|
+
true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def requeue(reservation, duration_ms:, failure_summary:, errors:)
|
|
139
|
+
reply = fenced(:requeue, reservation, reservation.unit_id, reservation.unit_type,
|
|
140
|
+
max_requeues, requeue_tolerance, duration_ms.to_i, failure_summary.to_s,
|
|
141
|
+
errors_json(errors))
|
|
142
|
+
status = reply.first == "REQUEUED" ? :requeued : :finalized
|
|
143
|
+
RequeueResult.new(status: status, retry_index: reply[1])
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def record_abandoned(reservation, elapsed_ms:)
|
|
147
|
+
fenced(:abandoned, reservation, reservation.unit_id, elapsed_ms.to_i)
|
|
148
|
+
true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# -- unfenced writes ---------------------------------------------------
|
|
152
|
+
|
|
153
|
+
# Works before initialization: a boot error may precede `ready`.
|
|
154
|
+
def record_worker_error(worker_id:, phase:, error:, unit_id: nil)
|
|
155
|
+
phase = phase.to_s
|
|
156
|
+
raise ArgumentError, "phase must be one of #{PHASES.join(", ")}" unless PHASES.include?(phase)
|
|
157
|
+
|
|
158
|
+
event = {
|
|
159
|
+
"type" => "worker_error", "worker_id" => worker_id, "phase" => phase, "unit_id" => unit_id,
|
|
160
|
+
"class" => error.class.name,
|
|
161
|
+
"message" => ErrorPayload.truncate(error.message.to_s, ErrorPayload::MESSAGE_BYTES),
|
|
162
|
+
"backtrace" => (error.backtrace || []).first(ErrorPayload::BACKTRACE_LINES)
|
|
163
|
+
}
|
|
164
|
+
transition("worker_error", worker_id, JSON.generate(event))
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def record_stale_rejected(reservation, operation:)
|
|
169
|
+
event = {
|
|
170
|
+
"type" => "stale_rejected", "unit_id" => reservation.unit_id, "worker_id" => reservation.consumer,
|
|
171
|
+
"retry_index" => reservation.retry_index, "reclaim_count" => reservation.reclaim_count,
|
|
172
|
+
"ownership_generation" => reservation.ownership_generation, "operation" => operation.to_s,
|
|
173
|
+
"stream" => reservation.stream, "entry_id" => reservation.entry_id,
|
|
174
|
+
"delivery_count" => reservation.delivery_count
|
|
175
|
+
}
|
|
176
|
+
transition("stale_rejected", reservation.consumer, JSON.generate(event))
|
|
177
|
+
true
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def touch_liveness(worker_id, current_unit: nil)
|
|
181
|
+
check!(transition("liveness", worker_id, current_unit.to_s), :liveness, current_unit)
|
|
182
|
+
true
|
|
183
|
+
end
|
|
184
|
+
# rubocop:enable Naming/PredicateMethod
|
|
185
|
+
|
|
186
|
+
# -- completion --------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def complete?
|
|
189
|
+
finalized, total = meta_fields("finalized_count", "total_units")
|
|
190
|
+
finalized.to_i == total.to_i
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def finalized_count = meta_fields("finalized_count").first.to_i
|
|
194
|
+
def total_units = meta_fields("total_units").first.to_i
|
|
195
|
+
|
|
196
|
+
# -- reads -------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def attempt_events
|
|
199
|
+
redis.xrange(keyset.attempts).map { |_id, fields| JSON.parse(fields["json"]) }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def workers = json_hash(keyset.workers)
|
|
203
|
+
def unit_states = json_hash(keyset.unit_state)
|
|
204
|
+
|
|
205
|
+
# Existing key names of this build, for TTL scans.
|
|
206
|
+
def keys
|
|
207
|
+
found = []
|
|
208
|
+
redis.scan_each(match: keyset.pattern) { |k| found << k }
|
|
209
|
+
found.sort
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def ms(seconds) = (seconds * 1000).to_i
|
|
215
|
+
|
|
216
|
+
def json_hash(key)
|
|
217
|
+
redis.hgetall(key).transform_values { |v| JSON.parse(v) }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def meta_fields(*names)
|
|
221
|
+
values = redis.hmget(keyset.meta, *names)
|
|
222
|
+
raise BuildStateMissing, "build #{build_id}: meta is missing" if values.all?(&:nil?)
|
|
223
|
+
|
|
224
|
+
values
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def errors_json(errors)
|
|
228
|
+
return "" if errors.nil?
|
|
229
|
+
|
|
230
|
+
JSON.generate(ErrorPayload.cap(errors))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def budget_meta
|
|
234
|
+
BUDGET_FIELDS.to_h { |f| [f.to_s, public_send(f).to_s] }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def init_script(mode, token, manifest, unit_ids_json)
|
|
238
|
+
fields = manifest.to_meta.merge(budget_meta)
|
|
239
|
+
argv = [mode, token, ms(ttl), ms(tombstone_ttl), JSON.generate(fields), unit_ids_json, "file"]
|
|
240
|
+
run_script(:init, init_keys, argv)
|
|
241
|
+
rescue Redis::CommandError => e
|
|
242
|
+
code = e.message[/\A(?:ERR\s+)?([A-Z_]+)/, 1]
|
|
243
|
+
raise INIT_ERRORS.fetch(code), "build #{build_id}: #{code}" if INIT_ERRORS.key?(code)
|
|
244
|
+
|
|
245
|
+
raise
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def init_keys
|
|
249
|
+
[keyset.units, keyset.units_priority, keyset.attempts, keyset.meta, keyset.unit_state,
|
|
250
|
+
keyset.leader, keyset.exists]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def transition_keys
|
|
254
|
+
[keyset.units, keyset.units_priority, keyset.attempts, keyset.meta, keyset.unit_state, keyset.workers]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def transition(mode, worker_id, *argv)
|
|
258
|
+
run_script(:transition, transition_keys, [mode, worker_id, ms(ttl), *argv])
|
|
259
|
+
rescue Redis::CommandError => e
|
|
260
|
+
raise CorruptBuild, "build #{build_id}: #{e.message}" if e.message.start_with?("NOGROUP")
|
|
261
|
+
|
|
262
|
+
raise
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Runs a mode carrying the reservation handle and maps STALE/CORRUPT.
|
|
266
|
+
def fenced(mode, reservation, *argv)
|
|
267
|
+
reply = transition(mode.to_s, reservation.consumer, reservation.stream, reservation.entry_id,
|
|
268
|
+
reservation.delivery_count, *argv)
|
|
269
|
+
check!(reply, mode, reservation.unit_id)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def check!(reply, operation, unit_id)
|
|
273
|
+
case reply.first
|
|
274
|
+
when "STALE"
|
|
275
|
+
raise StaleReservation, "#{operation} rejected for #{unit_id}: ownership has moved"
|
|
276
|
+
when "CORRUPT"
|
|
277
|
+
raise CorruptBuild, "build #{build_id}: state missing during #{operation}#{" of #{unit_id}" if unit_id}"
|
|
278
|
+
end
|
|
279
|
+
reply
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# SCRIPT LOAD once per process, EVALSHA thereafter, EVAL if the server
|
|
283
|
+
# lost the script (restart, SCRIPT FLUSH).
|
|
284
|
+
def run_script(name, keys, argv)
|
|
285
|
+
sha = (@shas[name] ||= redis.script(:load, SCRIPTS.fetch(name)))
|
|
286
|
+
redis.evalsha(sha, keys: keys, argv: argv)
|
|
287
|
+
rescue Redis::NoScriptError
|
|
288
|
+
@shas.delete(name)
|
|
289
|
+
redis.eval(SCRIPTS.fetch(name), keys: keys, argv: argv)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def read_group(worker_id, stream, block_ms)
|
|
293
|
+
key = keyset.stream(stream)
|
|
294
|
+
reply = redis.xreadgroup(Keys::CONSUMER_GROUP, worker_id, key, ">", count: 1, block: block_ms)
|
|
295
|
+
entry = reply[key]&.first
|
|
296
|
+
return nil unless entry
|
|
297
|
+
|
|
298
|
+
[stream, entry[0], entry[1]]
|
|
299
|
+
rescue Redis::CommandError => e
|
|
300
|
+
raise CorruptBuild, "build #{build_id}: #{e.message}" if e.message.start_with?("NOGROUP")
|
|
301
|
+
|
|
302
|
+
raise
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hopper
|
|
5
|
+
# Storage-agnostic queue protocol. See docs/DESIGN.md for the full contract.
|
|
6
|
+
# The only adapter is Queue::RedisStreams. No RSpec knowledge lives here.
|
|
7
|
+
module Queue
|
|
8
|
+
Status = Data.define(:state, :tombstone, :meta) do
|
|
9
|
+
def ready? = state == "ready"
|
|
10
|
+
def init_failed? = state == "init_failed"
|
|
11
|
+
def present? = !meta.nil?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
RequeueResult = Data.define(:status, :retry_index) do
|
|
15
|
+
def requeued? = status == :requeued
|
|
16
|
+
def finalized? = status == :finalized
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
PHASES = %w[boot init reserve execution redis formatter].freeze
|
|
20
|
+
REASONS = %w[test_failure retry_budget_exhausted reclaim_budget_exhausted].freeze
|
|
21
|
+
OUTCOMES = %i[passed failed].freeze
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "redis"
|
|
6
|
+
|
|
7
|
+
module RSpec
|
|
8
|
+
module Hopper
|
|
9
|
+
# Produces the one authoritative verdict for a build. Never loads spec files,
|
|
10
|
+
# never writes to Redis, and knows nothing about RSpec.
|
|
11
|
+
class Report
|
|
12
|
+
VERDICTS = %w[passed failed incomplete init_failed missing expired unreachable].freeze
|
|
13
|
+
|
|
14
|
+
Outcome = Data.define(:verdict, :exit_code, :headline, :details) do
|
|
15
|
+
def initialize(verdict:, exit_code:, headline:, details: [])
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
21
|
+
EPOCH_MS = -> { Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) }
|
|
22
|
+
|
|
23
|
+
attr_reader :config, :queue, :summary
|
|
24
|
+
|
|
25
|
+
# `clock` (monotonic seconds) budgets --timeout and --init-timeout.
|
|
26
|
+
# `wall_clock` (epoch ms) is compared with Redis-stamped `ready_at` and
|
|
27
|
+
# `workers.last_seen` for --inactive-timeout.
|
|
28
|
+
def initialize(config:, queue:, clock: MONOTONIC, sleeper: ->(s) { sleep(s) }, poll_interval: 1.0,
|
|
29
|
+
wall_clock: EPOCH_MS)
|
|
30
|
+
@config = config
|
|
31
|
+
@queue = queue
|
|
32
|
+
@clock = clock
|
|
33
|
+
@sleeper = sleeper
|
|
34
|
+
@poll_interval = poll_interval
|
|
35
|
+
@wall_clock = wall_clock
|
|
36
|
+
@summary = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Runs the wait phases, prints a human summary, writes the configured
|
|
40
|
+
# output files and returns the exit code.
|
|
41
|
+
def run(out: $stdout)
|
|
42
|
+
reset
|
|
43
|
+
outcome = evaluate
|
|
44
|
+
@summary = build_summary(outcome)
|
|
45
|
+
write_outputs
|
|
46
|
+
print_outcome(out, outcome)
|
|
47
|
+
outcome.exit_code
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def attempt_log = @attempt_log ||= AttemptLog.new(@events)
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def reset
|
|
55
|
+
@started = @clock.call
|
|
56
|
+
@state = nil
|
|
57
|
+
@manifest = nil
|
|
58
|
+
@finalized_count = nil
|
|
59
|
+
@workers = {}
|
|
60
|
+
@events = []
|
|
61
|
+
@attempt_log = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def evaluate
|
|
65
|
+
status = wait_for_status
|
|
66
|
+
return missing unless status
|
|
67
|
+
return expired unless status.present?
|
|
68
|
+
|
|
69
|
+
@state = status.state
|
|
70
|
+
@manifest = Manifest.from_meta(status.meta)
|
|
71
|
+
return init_failed if status.init_failed?
|
|
72
|
+
|
|
73
|
+
incomplete_reason = wait_for_completion
|
|
74
|
+
collect_events
|
|
75
|
+
incomplete_reason ? incomplete(*incomplete_reason) : verdict
|
|
76
|
+
rescue Redis::BaseConnectionError, RedisUnreachable => e
|
|
77
|
+
Outcome.new(verdict: "unreachable", exit_code: ExitCode::INFRASTRUCTURE,
|
|
78
|
+
headline: "redis unreachable: #{e.message}")
|
|
79
|
+
rescue BuildStateMissing
|
|
80
|
+
expired
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# --- wait phases -------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def wait_for_status
|
|
86
|
+
loop do
|
|
87
|
+
status = @queue.status
|
|
88
|
+
return status if status.present? || status.tombstone
|
|
89
|
+
return nil if elapsed >= config.init_timeout
|
|
90
|
+
|
|
91
|
+
@sleeper.call(@poll_interval)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Returns nil once every unit is finalized, otherwise why it gave up:
|
|
96
|
+
# `[:timeout]` or `[:inactive, idle_seconds]`.
|
|
97
|
+
def wait_for_completion
|
|
98
|
+
loop do
|
|
99
|
+
@finalized_count = @queue.finalized_count
|
|
100
|
+
@workers = @queue.workers
|
|
101
|
+
return nil if @finalized_count >= @manifest.total_units
|
|
102
|
+
return [:timeout] if elapsed >= config.timeout
|
|
103
|
+
|
|
104
|
+
idle = idle_seconds
|
|
105
|
+
return [:inactive, idle] if idle > config.inactive_timeout
|
|
106
|
+
|
|
107
|
+
@sleeper.call(@poll_interval)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def collect_events
|
|
112
|
+
@events = @queue.attempt_events
|
|
113
|
+
@attempt_log = nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def elapsed = @clock.call - @started
|
|
117
|
+
|
|
118
|
+
# Seconds since the later of ready_at and the most recent worker liveness.
|
|
119
|
+
def idle_seconds
|
|
120
|
+
stamps = [@manifest.ready_at, *@workers.values.map { |w| w["last_seen"] }].compact
|
|
121
|
+
return 0.0 if stamps.empty?
|
|
122
|
+
|
|
123
|
+
(@wall_clock.call - stamps.max) / 1000.0
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# --- outcomes ------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def missing
|
|
129
|
+
Outcome.new(verdict: "missing", exit_code: ExitCode::INFRASTRUCTURE,
|
|
130
|
+
headline: "build #{config.build_id} never initialized " \
|
|
131
|
+
"(no manifest or tombstone after #{config.init_timeout}s)")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def expired
|
|
135
|
+
Outcome.new(verdict: "expired", exit_code: ExitCode::INCOMPLETE,
|
|
136
|
+
headline: "build #{config.build_id} was previously initialized; its state is gone " \
|
|
137
|
+
"(expired or evicted)")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def init_failed
|
|
141
|
+
collect_events
|
|
142
|
+
Outcome.new(verdict: "init_failed", exit_code: ExitCode::INCOMPLETE,
|
|
143
|
+
headline: "build #{config.build_id} failed to initialize: " \
|
|
144
|
+
"#{@manifest.load_errors.size} spec file load error(s)",
|
|
145
|
+
details: @manifest.load_errors)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Built only after collect_events so the never-finalized list is current.
|
|
149
|
+
def incomplete(cause, idle = nil)
|
|
150
|
+
headline = if cause == :timeout
|
|
151
|
+
"incomplete: #{gap} after #{config.timeout}s (--timeout)"
|
|
152
|
+
else
|
|
153
|
+
"incomplete: workers inactive for #{idle.round}s " \
|
|
154
|
+
"(--inactive-timeout #{config.inactive_timeout}s); #{gap}"
|
|
155
|
+
end
|
|
156
|
+
Outcome.new(verdict: "incomplete", exit_code: ExitCode::INCOMPLETE, headline: headline,
|
|
157
|
+
details: never_finalized_lines)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def verdict
|
|
161
|
+
if @manifest.empty? && !config.allow_empty
|
|
162
|
+
Outcome.new(verdict: "failed", exit_code: ExitCode::TEST_FAILURE,
|
|
163
|
+
headline: "#{count(@manifest.file_args.size, "file")} given, 0 examples selected",
|
|
164
|
+
details: @manifest.file_args)
|
|
165
|
+
elsif @manifest.total_examples < config.min_examples
|
|
166
|
+
Outcome.new(verdict: "failed", exit_code: ExitCode::TEST_FAILURE,
|
|
167
|
+
headline: "#{@manifest.total_examples} examples selected, " \
|
|
168
|
+
"fewer than --min-examples #{config.min_examples}")
|
|
169
|
+
elsif attempt_log.failed.any?
|
|
170
|
+
Outcome.new(verdict: "failed", exit_code: ExitCode::TEST_FAILURE,
|
|
171
|
+
headline: "#{attempt_log.failed.size} of #{@manifest.total_units} units failed",
|
|
172
|
+
details: failed_lines)
|
|
173
|
+
else
|
|
174
|
+
Outcome.new(verdict: "passed", exit_code: ExitCode::OK, headline: "passed")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def gap = "#{@finalized_count} of #{@manifest.total_units} units finalized"
|
|
179
|
+
|
|
180
|
+
def count(number, noun) = "#{number} #{number == 1 ? noun : "#{noun}s"}"
|
|
181
|
+
|
|
182
|
+
def never_finalized
|
|
183
|
+
return [] unless @manifest
|
|
184
|
+
|
|
185
|
+
attempt_log.never_finalized(@manifest.unit_ids)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def never_finalized_lines
|
|
189
|
+
never_finalized.map do |entry|
|
|
190
|
+
holder = entry[:last_worker_id] ? "last worker #{entry[:last_worker_id]}" : "never delivered"
|
|
191
|
+
"never finalized: #{entry[:unit_id]} (#{holder})"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def failed_lines
|
|
196
|
+
attempt_log.failed.map do |entry|
|
|
197
|
+
line = "failed: #{entry[:unit_id]} (#{entry[:reason]}, worker #{entry[:worker_id]})"
|
|
198
|
+
first = entry[:errors].find { |e| e.is_a?(Hash) && e["message"] }
|
|
199
|
+
line += " #{first["class"]}: #{first["message"].lines.first.to_s.strip}" if first
|
|
200
|
+
line
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# --- output --------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def print_outcome(out, outcome)
|
|
207
|
+
out.puts "rspec-hopper report: build #{config.build_id}: #{outcome.verdict}"
|
|
208
|
+
out.puts " #{outcome.headline}"
|
|
209
|
+
out.puts " units: #{totals_line}"
|
|
210
|
+
outcome.details.each { |line| out.puts " #{line}" }
|
|
211
|
+
print_log_notes(out)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def totals_line
|
|
215
|
+
return "unknown (no manifest)" unless @manifest
|
|
216
|
+
|
|
217
|
+
finalized = @finalized_count.nil? ? "" : ", #{@finalized_count} finalized"
|
|
218
|
+
"#{@manifest.total_units} total#{finalized}; examples: #{@manifest.total_examples} selected"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def print_log_notes(out)
|
|
222
|
+
return unless @manifest
|
|
223
|
+
|
|
224
|
+
flaky = attempt_log.flaky
|
|
225
|
+
out.puts " flaky (passed after requeue): #{flaky.join(", ")}" if flaky.any?
|
|
226
|
+
abandoned = attempt_log.abandoned
|
|
227
|
+
out.puts " abandoned (exceeded --max-unit-duration): #{abandoned.join(", ")}" if abandoned.any?
|
|
228
|
+
attempt_log.worker_errors.each do |e|
|
|
229
|
+
out.puts " worker error: #{e["worker_id"]} #{e["phase"]} #{e["class"]}: #{e["message"]}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def build_summary(outcome)
|
|
234
|
+
{
|
|
235
|
+
"build_id" => config.build_id,
|
|
236
|
+
"state" => @state,
|
|
237
|
+
"verdict" => outcome.verdict,
|
|
238
|
+
"exit_code" => outcome.exit_code,
|
|
239
|
+
"message" => outcome.headline,
|
|
240
|
+
"total_units" => @manifest&.total_units,
|
|
241
|
+
"total_examples" => @manifest&.total_examples,
|
|
242
|
+
"finalized_count" => @finalized_count,
|
|
243
|
+
**log_summary,
|
|
244
|
+
"workers" => @workers,
|
|
245
|
+
**manifest_summary
|
|
246
|
+
}
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def log_summary
|
|
250
|
+
{
|
|
251
|
+
"failed" => attempt_log.failed.map { |h| h.transform_keys(&:to_s) },
|
|
252
|
+
"flaky" => attempt_log.flaky,
|
|
253
|
+
"never_finalized" => never_finalized.map { |h| h.transform_keys(&:to_s) },
|
|
254
|
+
"abandoned" => attempt_log.abandoned,
|
|
255
|
+
"retry_counts" => attempt_log.retry_counts,
|
|
256
|
+
"reclaim_counts" => attempt_log.reclaim_counts,
|
|
257
|
+
"worker_errors" => attempt_log.worker_errors,
|
|
258
|
+
"stale_rejections" => attempt_log.stale_rejections.size
|
|
259
|
+
}
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def manifest_summary
|
|
263
|
+
{
|
|
264
|
+
"load_errors" => @manifest&.load_errors || [],
|
|
265
|
+
"file_args" => @manifest&.file_args || [],
|
|
266
|
+
"seed" => @manifest&.seed,
|
|
267
|
+
"fingerprint" => @manifest&.fingerprint,
|
|
268
|
+
"revision" => @manifest&.revision
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def write_outputs
|
|
273
|
+
write_file(config.summary_out, "#{JSON.pretty_generate(@summary)}\n") if config.summary_out
|
|
274
|
+
return unless config.failed_out
|
|
275
|
+
|
|
276
|
+
ids = @summary["failed"].map { |f| f["unit_id"] } + @summary["never_finalized"].map { |n| n["unit_id"] }
|
|
277
|
+
write_file(config.failed_out, ids.map { |id| "#{id}\n" }.join)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def write_file(path, content)
|
|
281
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
282
|
+
File.write(path, content)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpec
|
|
4
|
+
module Hopper
|
|
5
|
+
# The opaque handle for an in-flight stream entry. Every mutation of the
|
|
6
|
+
# entry is fenced on (stream, entry_id, consumer, delivery_count).
|
|
7
|
+
Reservation = Data.define(
|
|
8
|
+
:unit_id, :unit_type, :stream, :entry_id, :consumer, :delivery_count,
|
|
9
|
+
:retry_index, :reclaim_count
|
|
10
|
+
) do
|
|
11
|
+
def unit = Unit.new(id: unit_id, type: unit_type)
|
|
12
|
+
|
|
13
|
+
# Logical ownership generation across stream-entry replacement on retry.
|
|
14
|
+
# Recorded in events, never compared to a limit.
|
|
15
|
+
def ownership_generation = 1 + retry_index + reclaim_count
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|