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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +537 -0
  5. data/Rakefile +10 -0
  6. data/docs/DESIGN.md +386 -0
  7. data/exe/rspec-hopper +6 -0
  8. data/lib/rspec/hopper/attempt_log.rb +133 -0
  9. data/lib/rspec/hopper/ci_env.rb +87 -0
  10. data/lib/rspec/hopper/cli/formatter_args.rb +129 -0
  11. data/lib/rspec/hopper/cli/report.rb +117 -0
  12. data/lib/rspec/hopper/cli/work/parser.rb +166 -0
  13. data/lib/rspec/hopper/cli/work.rb +59 -0
  14. data/lib/rspec/hopper/cli.rb +61 -0
  15. data/lib/rspec/hopper/config.rb +48 -0
  16. data/lib/rspec/hopper/errors.rb +88 -0
  17. data/lib/rspec/hopper/example_reset.rb +41 -0
  18. data/lib/rspec/hopper/fingerprint.rb +185 -0
  19. data/lib/rspec/hopper/keys.rb +38 -0
  20. data/lib/rspec/hopper/manifest.rb +113 -0
  21. data/lib/rspec/hopper/queue/redis_streams/lua/init.lua +94 -0
  22. data/lib/rspec/hopper/queue/redis_streams/lua/transition.lua +476 -0
  23. data/lib/rspec/hopper/queue/redis_streams.rb +307 -0
  24. data/lib/rspec/hopper/queue.rb +24 -0
  25. data/lib/rspec/hopper/report.rb +286 -0
  26. data/lib/rspec/hopper/reservation.rb +18 -0
  27. data/lib/rspec/hopper/supervisor.rb +196 -0
  28. data/lib/rspec/hopper/unit.rb +15 -0
  29. data/lib/rspec/hopper/version.rb +7 -0
  30. data/lib/rspec/hopper/worker/buffering_reporter.rb +51 -0
  31. data/lib/rspec/hopper/worker/heartbeat.rb +178 -0
  32. data/lib/rspec/hopper/worker/requeue_policy.rb +86 -0
  33. data/lib/rspec/hopper/worker/runner.rb +28 -0
  34. data/lib/rspec/hopper/worker/suite.rb +205 -0
  35. data/lib/rspec/hopper/worker.rb +299 -0
  36. data/lib/rspec/hopper.rb +65 -0
  37. 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