minitest-distributed 0.2.13 → 0.3.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.
@@ -44,6 +44,27 @@ module Minitest
44
44
  extend T::Sig
45
45
  include CoordinatorInterface
46
46
 
47
+ class CoordinatorStateError < StandardError; end
48
+ private_constant :CoordinatorStateError
49
+
50
+ class StallProbe < T::Struct
51
+ const :acks, T.nilable(Integer)
52
+ const :size, T.nilable(Integer)
53
+ const :pending_count, Integer
54
+ const :lag, T.nilable(Integer)
55
+ const :production_complete, T::Boolean
56
+ const :production_heartbeat, T.nilable(Integer)
57
+ const :invalid_values, T::Array[String]
58
+ end
59
+ private_constant :StallProbe
60
+
61
+ class HeartbeatControl < T::Struct
62
+ const :mutex, Mutex, factory: -> { Mutex.new }
63
+ const :condition, ConditionVariable, factory: -> { ConditionVariable.new }
64
+ prop :stop_requested, T::Boolean, default: false
65
+ end
66
+ private_constant :HeartbeatControl
67
+
47
68
  sig { returns(Configuration) }
48
69
  attr_reader :configuration
49
70
 
@@ -62,60 +83,84 @@ module Minitest
62
83
  sig { returns(T::Set[EnqueuedRunnable]) }
63
84
  attr_reader :reclaimed_failed_tests
64
85
 
86
+ sig { returns(T.nilable(String)) }
87
+ attr_reader :stall_diagnostic
88
+
65
89
  sig { params(configuration: Configuration).void }
66
90
  def initialize(configuration:)
67
91
  @configuration = configuration
68
92
 
69
93
  @redis = T.let(nil, T.nilable(Redis))
94
+ @register_consumergroup_script = T.let(nil, T.nilable(String))
95
+ @read_results_script = T.let(nil, T.nilable(String))
96
+ @reset_results_script = T.let(nil, T.nilable(String))
97
+ @commit_results_script = T.let(nil, T.nilable(String))
98
+ @cleanup_script = T.let(nil, T.nilable(String))
99
+ @abort_script = T.let(nil, T.nilable(String))
100
+ @adjust_results_script = T.let(nil, T.nilable(String))
101
+ @publish_tests_script = T.let(nil, T.nilable(String))
102
+ @heartbeat_script = T.let(nil, T.nilable(String))
103
+ @mark_attempt_state_script = T.let(nil, T.nilable(String))
70
104
  @stream_key = T.let(key("queue"), String)
71
- @group_name = T.let("minitest-distributed", String)
105
+ @group_name = T.let(BASE_GROUP_NAME, String)
106
+ @attempt_generation = T.let(nil, T.nilable(String))
107
+ @production_heartbeat_error = T.let(nil, T.nilable(StandardError))
108
+ @production_heartbeat_control = T.let(nil, T.nilable(HeartbeatControl))
109
+ @mutating_script_mutex = T.let(Mutex.new, Mutex)
72
110
  @local_results = T.let(ResultAggregate.new, ResultAggregate)
73
111
  @combined_results = T.let(nil, T.nilable(ResultAggregate))
112
+ @combined_results_production_complete = T.let(nil, T.nilable(T::Boolean))
113
+ @attempt_truncated = T.let(false, T::Boolean)
114
+ @truncated_follower = T.let(false, T::Boolean)
115
+ @retry_refused_due_to_truncation = T.let(false, T::Boolean)
116
+ @truncation_state_invalid = T.let(false, T::Boolean)
117
+ @registration_rejected = T.let(false, T::Boolean)
118
+ @superseded = T.let(false, T::Boolean)
74
119
  @reclaimed_timeout_tests = T.let(Set.new, T::Set[EnqueuedRunnable])
75
120
  @reclaimed_failed_tests = T.let(Set.new, T::Set[EnqueuedRunnable])
76
121
  @aborted = T.let(false, T::Boolean)
122
+ @stall_diagnostic = T.let(nil, T.nilable(String))
123
+ @output = T.let(nil, T.untyped)
77
124
  end
78
125
 
79
126
  sig { override.params(reporter: Minitest::CompositeReporter, options: T::Hash[Symbol, T.untyped]).void }
80
127
  def register_reporters(reporter:, options:)
128
+ @output = options[:io]
81
129
  reporter << Reporters::RedisCoordinatorWarningsReporter.new(options[:io], options)
82
130
  end
83
131
 
84
132
  sig { override.returns(ResultAggregate) }
85
133
  def combined_results
86
134
  @combined_results ||= begin
87
- stats_as_string = redis.mget(
88
- key("runs"),
89
- key("assertions"),
90
- key("passes"),
91
- key("failures"),
92
- key("errors"),
93
- key("skips"),
94
- key("requeues"),
95
- key("discards"),
96
- key("acks"),
97
- key("size"),
135
+ keys = STATS_KEY_NAMES.map { |name| key(name) }
136
+ keys << key("production_complete")
137
+ response = T.cast(
138
+ execute_script(script_name: :read_results, keys: keys, argv: []),
139
+ T::Array[T.untyped],
98
140
  )
141
+ @combined_results_production_complete = response.fetch(0) == 1
142
+ stats_as_string = response.drop(1)
99
143
 
100
144
  ResultAggregate.new(
101
145
  max_failures: configuration.max_failures,
102
146
 
103
- runs: Integer(stats_as_string.fetch(0) || 0),
104
- assertions: Integer(stats_as_string.fetch(1) || 0),
105
- passes: Integer(stats_as_string.fetch(2) || 0),
106
- failures: Integer(stats_as_string.fetch(3) || 0),
107
- errors: Integer(stats_as_string.fetch(4) || 0),
108
- skips: Integer(stats_as_string.fetch(5) || 0),
109
- requeues: Integer(stats_as_string.fetch(6) || 0),
110
- discards: Integer(stats_as_string.fetch(7) || 0),
111
- acks: Integer(stats_as_string.fetch(8) || 0),
112
-
113
- # In the case where we have no build size number published yet, we initialize
114
- # thesize of the test suite to be arbitrarity large, to make sure it is
115
- # higher than the number of acks, so the run is not consider completed yet.
116
- size: Integer(stats_as_string.fetch(9) || 2_147_483_647),
147
+ runs: Integer(stats_as_string.fetch(0).then { |value| value == "" ? 0 : value }),
148
+ assertions: Integer(stats_as_string.fetch(1).then { |value| value == "" ? 0 : value }),
149
+ passes: Integer(stats_as_string.fetch(2).then { |value| value == "" ? 0 : value }),
150
+ failures: Integer(stats_as_string.fetch(3).then { |value| value == "" ? 0 : value }),
151
+ errors: Integer(stats_as_string.fetch(4).then { |value| value == "" ? 0 : value }),
152
+ skips: Integer(stats_as_string.fetch(5).then { |value| value == "" ? 0 : value }),
153
+ requeues: Integer(stats_as_string.fetch(6).then { |value| value == "" ? 0 : value }),
154
+ discards: Integer(stats_as_string.fetch(7).then { |value| value == "" ? 0 : value }),
155
+ acks: Integer(stats_as_string.fetch(8).then { |value| value == "" ? 0 : value }),
156
+
157
+ # Before the producer initializes counters, a sentinel prevents the
158
+ # absent size from making an unpublished run appear complete.
159
+ size: Integer(stats_as_string.fetch(9).then { |value| value == "" ? 2_147_483_647 : value }),
117
160
  )
118
161
  end
162
+ rescue ArgumentError, TypeError => parse_error
163
+ raise CoordinatorStateError, "invalid Redis aggregate: #{parse_error.message}"
119
164
  end
120
165
 
121
166
  sig { override.returns(T::Boolean) }
@@ -123,124 +168,354 @@ module Minitest
123
168
  @aborted
124
169
  end
125
170
 
171
+ sig { override.returns(T::Boolean) }
172
+ def valid_combined_results?
173
+ # Reporter success must be based on one fresh atomic snapshot. Counters
174
+ # alone can look complete after production_complete is evicted.
175
+ @combined_results = nil
176
+ results = combined_results
177
+ results.valid? && @combined_results_production_complete == true
178
+ rescue Redis::BaseError, CoordinatorStateError
179
+ false
180
+ end
181
+
182
+ sig { override.returns(T::Boolean) }
183
+ def persisted_truncation?
184
+ truncated_key = key("truncated")
185
+ generation_key = key("truncated_generation")
186
+ return false unless redis.type(truncated_key) == "string"
187
+ return false unless redis.type(generation_key) == "string"
188
+
189
+ redis.get(truncated_key) == "1" && !redis.get(generation_key).to_s.empty?
190
+ rescue Redis::BaseError
191
+ false
192
+ end
193
+
194
+ sig { returns(T::Boolean) }
195
+ def stalled?
196
+ !stall_diagnostic.nil?
197
+ end
198
+
199
+ sig { returns(T::Boolean) }
200
+ def registration_rejected?
201
+ @registration_rejected
202
+ end
203
+
204
+ sig { returns(T::Boolean) }
205
+ def retry_refused_due_to_truncation?
206
+ @retry_refused_due_to_truncation
207
+ end
208
+
209
+ sig { returns(T::Boolean) }
210
+ def current_attempt_truncated?
211
+ @attempt_truncated
212
+ end
213
+
214
+ sig { returns(T::Boolean) }
215
+ def truncation_state_invalid?
216
+ @truncation_state_invalid
217
+ end
218
+
219
+ sig { returns(T::Boolean) }
220
+ def superseded?
221
+ @superseded
222
+ end
223
+
126
224
  sig { override.params(test_selector: TestSelector).void }
127
225
  def produce(test_selector:)
128
- # Whoever ends up creating the consumer group will act as leader,
129
- # and publish the list of tests to the stream.
130
- consumer_group_exists = false
131
- initial_attempt = begin
132
- # When using `redis.multi`, the second DEL command gets executed even if the initial GROUP
133
- # fails. This is bad, because only the leader should be issuing the DEL command.
134
- # When using EVAL and a Lua script, the script aborts after the first XGROUP command
135
- # fails, and the DEL never gets executed for followers.
136
- keys_deleted = redis.evalsha(
137
- register_consumergroup_script,
138
- keys: [stream_key, key("size"), key("acks")],
139
- argv: [group_name],
140
- )
141
- keys_deleted == 0
142
- rescue Redis::CommandError => ce
143
- if ce.message.include?("BUSYGROUP")
144
- # If Redis returns a BUSYGROUP error, it means that the consumer group already
145
- # exists. In our case, it means that another worker managed to successfully
146
- # run the XGROUP command, and will act as leader and publish the tests.
147
- # This worker can simply move on the consumer mode.
148
- consumer_group_exists = true
149
- else
150
- raise
226
+ production_heartbeat_thread = T.let(nil, T.nilable(Thread))
227
+ propagate_heartbeat_error = T.let(false, T::Boolean)
228
+
229
+ registration_keys = [
230
+ stream_key,
231
+ key("attempt_generation"),
232
+ key("stalled"),
233
+ key("production_complete"),
234
+ key("production_heartbeat"),
235
+ key("retry_set"),
236
+ ]
237
+ registration_keys.concat(STATS_KEY_NAMES.map { |name| key(name) })
238
+ registration_keys.concat(LIST_KEY_RESULT_TYPES.map { |result_type| list_key(result_type.serialize) })
239
+ registration_keys.push(
240
+ key("truncated"),
241
+ key("completed_at"),
242
+ key("retry_snapshot_digest"),
243
+ key("truncated_generation"),
244
+ key("retention_ttl"),
245
+ )
246
+ registration = T.let(nil, T.untyped)
247
+ registration_mode = T.let(-1, Integer)
248
+ leader = T.let(false, T::Boolean)
249
+ awaited_generation = T.let(nil, T.nilable(String))
250
+ loop do
251
+ registration = T.unsafe(execute_script(
252
+ script_name: :register_consumergroup,
253
+ keys: registration_keys,
254
+ argv: [
255
+ BASE_GROUP_NAME,
256
+ configuration.key_ttl_seconds,
257
+ configuration.max_failures || "",
258
+ SecureRandom.uuid,
259
+ configuration.completion_grace_seconds,
260
+ awaited_generation || "",
261
+ ],
262
+ ))
263
+
264
+ leader = registration.fetch(0) == 1
265
+ @attempt_generation = String(registration.fetch(1))
266
+ @group_name = "#{BASE_GROUP_NAME}-#{@attempt_generation}"
267
+ registration_mode = Integer(registration.fetch(2))
268
+ if registration_mode == -3
269
+ @aborted = true
270
+ @truncated_follower = true
271
+ @retry_refused_due_to_truncation = true
272
+ elsif registration_mode == -4
273
+ @aborted = true
274
+ @registration_rejected = true
275
+ emit_message(<<~ERROR)
276
+ ERROR: minitest-distributed rejected a Redis key TTL change for an existing run.
277
+ run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
278
+ Active workers must use the same key TTL, and later retries cannot decrease the retained TTL.
279
+ ERROR
280
+ elsif registration_mode == -5
281
+ @aborted = true
282
+ @truncation_state_invalid = true
283
+ emit_message(<<~ERROR)
284
+ ERROR: minitest-distributed cannot retry because the retained truncation fence is incomplete.
285
+ run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
286
+ The run remains failed; restore or expire its retained state before reusing this run ID.
287
+ ERROR
151
288
  end
289
+ break unless registration_mode == -2
290
+
291
+ sleep(Float(registration.fetch(5)))
292
+ awaited_generation = String(registration.fetch(1))
152
293
  end
294
+ return unless leader
153
295
 
154
- return if consumer_group_exists
296
+ previous_failures = T.cast(registration.fetch(3), T::Array[String])
297
+ previous_errors = T.cast(registration.fetch(4), T::Array[String])
298
+ production_heartbeat_thread = start_production_heartbeat unless registration_mode == 3
155
299
 
156
300
  tests = T.let(
157
- if initial_attempt
158
- # If this is the first attempt for this run ID, we will schedule the full
159
- # test suite as returned by the test selector to run.
160
-
301
+ case registration_mode
302
+ when 0 # First attempt for a new run ID.
161
303
  tests_from_selector = test_selector.tests
162
- adjust_combined_results(ResultAggregate.new(size: tests_from_selector.size))
304
+ adjust_combined_results(
305
+ ResultAggregate.new(size: tests_from_selector.size),
306
+ allow_missing_stats: true,
307
+ )
163
308
  tests_from_selector
164
-
165
- elsif configuration.retry_failures
166
- # Before starting a retry attempt, we first check if the previous attempt
167
- # was aborted before it was completed. If this is the case, we cannot use
168
- # retry mode, and should immediately fail the attempt.
169
- if combined_results.abort?
170
- # We mark this run as aborted, which causes this worker to not be successful.
171
- @aborted = true
172
-
173
- # We still publish an empty size run to Redis, so if there are any followers,
174
- # they will wind down normally. Only the leader will exit
175
- # with a non-zero exit status and fail the build; any follower will
176
- # exit with status 0.
309
+ when 1 # Valid completed attempt; use selective retry behavior.
310
+ if configuration.retry_failures
311
+ test_identifiers_to_retry = T.let(previous_failures + previous_errors, T::Array[String])
312
+ retry_tests = materialize_retry_tests(test_identifiers_to_retry)
313
+ if retry_tests
314
+ total_failures = retry_tests.length
315
+ adjust_combined_results(
316
+ ResultAggregate.new(
317
+ size: total_failures,
318
+ failures: -previous_failures.length,
319
+ errors: -previous_errors.length,
320
+ requeues: total_failures,
321
+ ),
322
+ clear_retry_lists: true,
323
+ )
324
+ retry_tests
325
+ else
326
+ emit_message(<<~WARNING)
327
+ WARNING: The previous attempt retained an invalid retry identifier.
328
+ Running the full test suite instead of a selective retry.
329
+ WARNING
330
+ tests_from_selector = test_selector.tests
331
+ reset_combined_results_for_full_rerun(size: tests_from_selector.size)
332
+ tests_from_selector
333
+ end
334
+ else
177
335
  adjust_combined_results(ResultAggregate.new(size: 0))
178
336
  []
179
- else
180
- previous_failures, previous_errors, _deleted = redis.multi do |pipeline|
181
- pipeline.lrange(list_key(ResultType::Failed.serialize), 0, -1)
182
- pipeline.lrange(list_key(ResultType::Error.serialize), 0, -1)
183
- pipeline.del(list_key(ResultType::Failed.serialize), list_key(ResultType::Error.serialize))
184
- end
185
-
186
- # We set the `size` key to the number of tests we are planning to schedule.
187
- # We also adjust the number of failures and errors back to 0.
188
- # We set the number of requeues to the number of tests that failed, so the
189
- # run statistics will reflect that we retried some failed test.
190
- #
191
- # However, normally requeues are not acked, as we expect the test to be acked
192
- # by another worker later. This makes the test loop think iot is already done.
193
- # To prevent this, we initialize the number of acks negatively, so it evens out
194
- # in the statistics.
195
- total_failures = previous_failures.length + previous_errors.length
196
- adjust_combined_results(ResultAggregate.new(
197
- size: total_failures,
198
- failures: -previous_failures.length,
199
- errors: -previous_errors.length,
200
- requeues: total_failures,
201
- ))
202
-
203
- # For subsequent attempts, we check the list of previous failures and
204
- # errors, and only schedule to re-run those tests. This allows for faster
205
- # retries of potentially flaky tests.
206
- test_identifiers_to_retry = T.let(previous_failures + previous_errors, T::Array[String])
207
- test_identifiers_to_retry.map { |identifier| DefinedRunnable.from_identifier(identifier) }
208
337
  end
209
- else
210
- adjust_combined_results(ResultAggregate.new(size: 0))
338
+ when 2 # Inconsistent or explicitly stalled state; rerun everything.
339
+ emit_message(<<~WARNING)
340
+ WARNING: The previous attempt lost Redis coordinator state.
341
+ Running the full test suite instead of a selective retry.
342
+ WARNING
343
+ tests_from_selector = test_selector.tests
344
+ adjust_combined_results(
345
+ ResultAggregate.new(size: tests_from_selector.size),
346
+ allow_missing_stats: true,
347
+ )
348
+ tests_from_selector
349
+ when 3 # The previous attempt intentionally stopped at max_failures.
350
+ @aborted = true
351
+ @retry_refused_due_to_truncation = true
211
352
  []
353
+ else
354
+ raise "Unknown Redis registration mode: #{registration_mode}"
212
355
  end,
213
356
  T::Array[Minitest::Runnable],
214
357
  )
215
358
 
216
- redis.pipelined do |pipeline|
217
- tests.each do |test|
218
- pipeline.xadd(stream_key, { class_name: T.must(test.class.name), method_name: test.name })
359
+ unless @retry_refused_due_to_truncation
360
+ begin
361
+ publish_tests(tests)
362
+ propagate_heartbeat_error = true
363
+ rescue Redis::CommandError => error
364
+ raise unless handle_publish_race(error)
219
365
  end
220
366
  end
367
+ ensure
368
+ if production_heartbeat_thread
369
+ stop_production_heartbeat(
370
+ production_heartbeat_thread,
371
+ propagate_error: propagate_heartbeat_error,
372
+ )
373
+ end
221
374
  end
222
375
 
376
+ # The loop intentionally keeps the stale/fresh claim and diagnostic state
377
+ # transitions together so each iteration observes one coherent snapshot.
378
+ # rubocop:disable Metrics/BlockNesting, Lint/RedundantCopDisableDirective
223
379
  sig { override.params(reporter: AbstractReporter).void }
224
380
  def consume(reporter:)
381
+ return if @retry_refused_due_to_truncation || @registration_rejected || @truncation_state_invalid || superseded?
382
+
225
383
  exponential_backoff = INITIAL_BACKOFF
384
+ last_progress_at = monotonic_time
385
+ initial_results = combined_results
386
+ observed_acks = T.let(initial_results.acks, T.nilable(Integer))
387
+ observed_size = T.let(initial_results.size, T.nilable(Integer))
388
+ observed_production_heartbeat = current_production_heartbeat
389
+ pending_stall_warning_emitted = T.let(false, T::Boolean)
390
+ drained_mismatch_detected_at = T.let(nil, T.nilable(Float))
391
+ incomplete_production_detected_at = T.let(nil, T.nilable(Float))
392
+
226
393
  loop do
394
+ # Other workers can advance or complete the run without touching this
395
+ # process's memoized aggregate. Refresh once per polling iteration.
396
+ @combined_results = nil
397
+
227
398
  # First, see if there are any pending tests from other workers to claim.
228
399
  stale_runnables = claim_stale_runnables
229
400
  process_batch(stale_runnables, reporter)
401
+ break if commit_observed_truncation? || superseded?
230
402
 
231
403
  # Then, try to process a regular batch of messages
232
404
  fresh_runnables = claim_fresh_runnables(block: exponential_backoff)
233
405
  process_batch(fresh_runnables, reporter)
406
+ break if commit_observed_truncation? || superseded?
407
+
408
+ run_results = combined_results
234
409
 
235
410
  # If we have acked the same amount of tests as we were supposed to, the run
236
411
  # is complete and we can exit our loop. Generally, only one worker will detect
237
- # this condition. The pther workers will quit their consumer loop because the
238
- # consumergroup will be deleted by the first worker, and their Redis commands
239
- # will start to fail - see the rescue block below.
240
- break if combined_results.complete?
412
+ # this condition. The other workers will quit their consumer loop because the
413
+ # consumer group will be deleted by the first worker, and their Redis commands
414
+ # will start to fail - see the rescue block below. Counters can be 0/0 before
415
+ # an empty run finishes publishing, so production completion is also required.
416
+ break if run_complete?(run_results)
417
+
418
+ # We also abort a run if we reach the maximum number of failures.
419
+ if run_results.abort? && !run_results.complete?
420
+ mark_run_truncated
421
+ break
422
+ end
423
+
424
+ processed_batch = stale_runnables.any? || fresh_runnables.any?
425
+ if processed_batch
426
+ last_progress_at = monotonic_time
427
+ observed_acks = run_results.acks
428
+ observed_size = run_results.size
429
+ drained_mismatch_detected_at = nil
430
+ incomplete_production_detected_at = nil
431
+ else
432
+ now = monotonic_time
433
+ confirmation_interval = [configuration.stall_timeout_seconds, STALL_CONFIRMATION_SECONDS].min
434
+ confirmation_due = if drained_mismatch_detected_at
435
+ now - drained_mismatch_detected_at >= confirmation_interval
436
+ elsif incomplete_production_detected_at
437
+ now - incomplete_production_detected_at >= configuration.stall_timeout_seconds
438
+ else
439
+ true
440
+ end
441
+
442
+ if now - last_progress_at >= configuration.stall_timeout_seconds && confirmation_due
443
+ probe = probe_stall
444
+ @combined_results = nil
445
+
446
+ if probe.invalid_values.any?
447
+ abort_with_diagnostic(<<~DIAGNOSTIC)
448
+ ERROR: minitest-distributed found invalid numeric Redis coordinator state.
449
+ #{format_probe_state(probe)}
450
+ Invalid values: #{probe.invalid_values.join(", ")}
451
+ DIAGNOSTIC
452
+ break
453
+ end
241
454
 
242
- # We also abort a run if we reach the maximum number of failures
243
- break if combined_results.abort?
455
+ # Another worker may have completed the run while our memoized aggregate
456
+ # was stale. Treat the fresh counters as authoritative.
457
+ if probe.production_complete && !probe.acks.nil? && probe.acks == probe.size
458
+ # The probe checks completion/liveness fields only. Validate all
459
+ # retained statistics and the same-snapshot production marker
460
+ # before accepting terminal success.
461
+ terminal_results = combined_results
462
+ break if run_complete?(terminal_results)
463
+ end
464
+
465
+ counters_changed = probe.acks != observed_acks || probe.size != observed_size
466
+ heartbeat_changed = probe.production_heartbeat != observed_production_heartbeat
467
+ if counters_changed
468
+ observed_acks = probe.acks
469
+ observed_size = probe.size
470
+ end
471
+ observed_production_heartbeat = probe.production_heartbeat
472
+
473
+ stream_empty = probe.pending_count.zero? && probe.lag == 0
474
+ if probe.production_complete && stream_empty
475
+ incomplete_production_detected_at = nil
476
+ if drained_mismatch_detected_at && !counters_changed
477
+ abort_stalled_run(probe)
478
+ break
479
+ else
480
+ # The probe is pipelined rather than transactional. Confirm an
481
+ # unchanged mismatch so a final atomic commit interleaved with the
482
+ # probe cannot cause a false abort.
483
+ drained_mismatch_detected_at = now
484
+ end
485
+ elsif !probe.production_complete && stream_empty
486
+ drained_mismatch_detected_at = nil
487
+ production_progressed = counters_changed || heartbeat_changed
488
+ if incomplete_production_detected_at && !production_progressed
489
+ abort_stalled_run(probe)
490
+ break
491
+ elsif production_progressed
492
+ # A live leader refreshes this heartbeat while it selects and
493
+ # publishes tests, so arbitrarily slow discovery is not mistaken
494
+ # for a dead producer.
495
+ incomplete_production_detected_at = nil
496
+ last_progress_at = now
497
+ else
498
+ # No producer heartbeat was observed. Confirm once more after a
499
+ # full stall interval before declaring the leader dead.
500
+ incomplete_production_detected_at = now
501
+ end
502
+ else
503
+ drained_mismatch_detected_at = nil
504
+ incomplete_production_detected_at = nil
505
+
506
+ if probe.pending_count > 0 && !pending_stall_warning_emitted
507
+ emit_message(format_pending_stall_warning(probe))
508
+ pending_stall_warning_emitted = true
509
+ end
510
+
511
+ # A non-empty PEL may be waiting for the legitimate
512
+ # test_timeout_seconds * test_batch_size reclaim path. Undelivered
513
+ # entries can likewise be claimed by another worker. Wait another
514
+ # full interval before probing again.
515
+ last_progress_at = now
516
+ end
517
+ end
518
+ end
244
519
 
245
520
  # To make sure we don't end up in a busy loop overwhelming Redis with commands
246
521
  # when there is no work to do, we increase the blocking time exponentially,
@@ -252,30 +527,73 @@ module Minitest
252
527
  # re-check `complete?` / `abort?` until the BLOCK returns, which manifests
253
528
  # as a long post-100% teardown hang when pipelined XACKs race the progress
254
529
  # reporter.
255
- exponential_backoff = if stale_runnables.empty? && fresh_runnables.empty?
256
- next_backoff(exponential_backoff)
257
- else
530
+ exponential_backoff = if processed_batch
258
531
  INITIAL_BACKOFF
532
+ else
533
+ next_backoff(exponential_backoff)
259
534
  end
260
535
  end
261
536
 
262
537
  cleanup
263
538
  rescue Redis::CommandError => ce
264
- if ce.message.start_with?("NOGROUP")
265
- # When a redis conumer group commands fails with a NOGROUP error, we assume the
266
- # consumer group was deleted by the first worker that detected the run is complete.
267
- # So this worker can exit its loop as well.
268
-
269
- # We have to invalidate the local combined_results cache so we get fresh
270
- # final values from Redis when we try to report results in our summarizer.
271
- @combined_results = nil
539
+ coordinator_state_error = ce.message.start_with?(
540
+ "NOGROUP",
541
+ "WRONGTYPE",
542
+ "COORDINATORSTATE",
543
+ "COORDINATORSTREAM",
544
+ "STALEATTEMPT",
545
+ "COORDINATORCONFIG",
546
+ ) || ce.message.include?("no such key")
547
+ if coordinator_state_error
548
+ # A normal cleanup and missing/evicted state can produce similar Redis
549
+ # errors. Fresh counters distinguish a terminal run from data loss.
550
+ handle_coordinator_state_error(ce)
551
+ cleanup if stalled?
272
552
  else
273
553
  raise
274
554
  end
555
+ rescue CoordinatorStateError => parse_error
556
+ abort_with_diagnostic(<<~DIAGNOSTIC)
557
+ ERROR: minitest-distributed could not parse Redis coordinator state.
558
+ run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
559
+ parse_error=#{parse_error.message.inspect}
560
+ DIAGNOSTIC
561
+ cleanup if stalled?
562
+ ensure
563
+ # Another worker may commit the final batch and clean up while this
564
+ # worker is unwinding from NOGROUP. Report and validate against one
565
+ # last fresh aggregate rather than a pre-cleanup local cache.
566
+ @combined_results = nil
275
567
  end
568
+ # rubocop:enable Metrics/BlockNesting, Lint/RedundantCopDisableDirective
276
569
 
277
570
  private
278
571
 
572
+ sig { params(error: Redis::CommandError).returns(T::Boolean) }
573
+ def handle_publish_race(error)
574
+ expected_race = error.message.start_with?("NOGROUP", "COORDINATORSTREAM", "STALEATTEMPT") ||
575
+ error.message.include?("no such key")
576
+ return false unless expected_race
577
+
578
+ if error.message.start_with?("STALEATTEMPT")
579
+ @superseded = true
580
+ return true
581
+ end
582
+
583
+ if attempt_truncated?
584
+ @attempt_truncated = true
585
+ @aborted = true
586
+ return true
587
+ end
588
+
589
+ if attempt_superseded?
590
+ @superseded = true
591
+ return true
592
+ end
593
+
594
+ false
595
+ end
596
+
279
597
  sig { returns(Redis) }
280
598
  def redis
281
599
  @redis ||= Redis.new(
@@ -286,7 +604,7 @@ module Minitest
286
604
  )
287
605
  end
288
606
 
289
- sig { returns(T.nilable(T::Array[Module])) }
607
+ sig { returns(T.nilable(T::Array[T.untyped])) }
290
608
  def custom_middlewares
291
609
  return unless ENV.key?("MINITEST_DISTRIBUTED_REDIS_LOG")
292
610
 
@@ -299,22 +617,1074 @@ module Minitest
299
617
  { log_file: logger }.compact
300
618
  end
301
619
 
620
+ sig { returns(String) }
621
+ def read_results_script
622
+ @read_results_script ||= redis.script(:load, <<~LUA)
623
+ -- KEYS: ten statistics followed by production_complete.
624
+ local max_safe_integer = 9007199254740991
625
+ local production_type = redis.call('TYPE', KEYS[11]).ok
626
+ local production_complete = false
627
+ if production_type == 'string' then
628
+ if redis.call('GET', KEYS[11]) ~= '1' then
629
+ return redis.error_reply('COORDINATORSTATE invalid production_complete marker')
630
+ end
631
+ production_complete = true
632
+ elseif production_type ~= 'none' then
633
+ return redis.error_reply('COORDINATORSTATE invalid production_complete type')
634
+ end
635
+
636
+ local values = {}
637
+ local existing_count = 0
638
+ for stat_index = 1, 10 do
639
+ local stat_type = redis.call('TYPE', KEYS[stat_index]).ok
640
+ if stat_type == 'none' then
641
+ if production_complete then
642
+ return redis.error_reply('COORDINATORSTATE missing terminal statistic ' .. KEYS[stat_index])
643
+ end
644
+ values[stat_index] = ''
645
+ elseif stat_type ~= 'string' then
646
+ return redis.error_reply('COORDINATORSTATE invalid statistic type ' .. KEYS[stat_index])
647
+ else
648
+ local validation = redis.pcall('INCRBY', KEYS[stat_index], 0)
649
+ if type(validation) == 'table' and validation.err then
650
+ return redis.error_reply('COORDINATORSTATE invalid statistic ' .. KEYS[stat_index])
651
+ end
652
+ local value = tonumber(validation)
653
+ if not value or value < 0 or value > max_safe_integer then
654
+ return redis.error_reply('COORDINATORSTATE unsafe statistic ' .. KEYS[stat_index])
655
+ end
656
+ values[stat_index] = value
657
+ existing_count = existing_count + 1
658
+ end
659
+ end
660
+
661
+ if existing_count > 0 and existing_count < 10 then
662
+ return redis.error_reply('COORDINATORSTATE partial aggregate statistics')
663
+ elseif existing_count == 10 then
664
+ local runs = values[1]
665
+ local reported = values[3] + values[4] + values[5] + values[6]
666
+ if values[9] > values[10] or runs < values[7] + values[8] or
667
+ runs - values[7] - values[8] ~= reported then
668
+ return redis.error_reply('COORDINATORSTATE inconsistent aggregate statistics')
669
+ end
670
+ end
671
+
672
+ local reply = {production_complete and 1 or 0}
673
+ for stat_index = 1, 10 do
674
+ if values[stat_index] == '' then
675
+ reply[#reply + 1] = ''
676
+ else
677
+ reply[#reply + 1] = values[stat_index]
678
+ end
679
+ end
680
+ return reply
681
+ LUA
682
+ end
683
+
302
684
  sig { returns(String) }
303
685
  def register_consumergroup_script
304
- @register_consumergroup_script ||= T.let(redis.script(:load, <<~LUA), T.nilable(String))
305
- -- Try to create the consumergroup. This will raise an error if the
306
- -- consumergroup has already been registered by somebody else, which
307
- -- means another worker will be acting as leader.
308
- -- In that case, the next Redis DEL call will not be executed.
309
- redis.call('XGROUP', 'CREATE', KEYS[1], ARGV[1], '0', 'MKSTREAM')
310
-
311
- -- The leader should reset the size and acks key for this run attempt.
312
- -- We return the number of keys that were deleted, which can be used to
313
- -- determine whether this was the first attempt for this run or not.
314
- return redis.call('DEL', KEYS[2], KEYS[3])
686
+ @register_consumergroup_script ||= redis.script(:load, <<~LUA)
687
+ -- KEYS: stream, generation token, stalled, production_complete,
688
+ -- production_heartbeat, retry_set, ten statistics, the
689
+ -- skipped/failed/error lists, truncated, completed_at, then the
690
+ -- completed retry-snapshot digest, truncating generation, and the
691
+ -- monotonic retained-state TTL.
692
+ local max_safe_integer = 9007199254740991
693
+ local invalid_active_state = false
694
+ local requested_ttl = tonumber(ARGV[2])
695
+ local retention_type = redis.call('TYPE', KEYS[24]).ok
696
+ local retained_ttl = nil
697
+ if retention_type == 'string' then
698
+ local validation = redis.pcall('INCRBY', KEYS[24], 0)
699
+ if type(validation) == 'table' and validation.err then
700
+ invalid_active_state = true
701
+ else
702
+ retained_ttl = tonumber(validation)
703
+ if not retained_ttl or retained_ttl <= 0 then
704
+ invalid_active_state = true
705
+ elseif requested_ttl < retained_ttl then
706
+ return {0, '', -4, {}, {}}
707
+ end
708
+ end
709
+ elseif retention_type ~= 'none' then
710
+ invalid_active_state = true
711
+ end
712
+
713
+ local stream_type = redis.call('TYPE', KEYS[1]).ok
714
+ local stream_exists = stream_type == 'stream'
715
+ if stream_type ~= 'none' and stream_type ~= 'stream' then
716
+ invalid_active_state = true
717
+ end
718
+
719
+ local generation_type = redis.call('TYPE', KEYS[2]).ok
720
+ local current_generation = nil
721
+ if generation_type == 'string' then
722
+ current_generation = redis.call('GET', KEYS[2])
723
+ if current_generation == '' then
724
+ current_generation = nil
725
+ invalid_active_state = true
726
+ end
727
+ elseif generation_type ~= 'none' then
728
+ invalid_active_state = true
729
+ end
730
+
731
+ local function read_marker(key)
732
+ local marker_type = redis.call('TYPE', key).ok
733
+ if marker_type == 'none' then
734
+ return false, false
735
+ elseif marker_type == 'string' and redis.call('GET', key) == '1' then
736
+ return true, false
737
+ end
738
+ return false, true
739
+ end
740
+
741
+ local stalled, stalled_invalid = read_marker(KEYS[3])
742
+ local production_complete, production_complete_invalid = read_marker(KEYS[4])
743
+ local truncated, truncated_invalid = read_marker(KEYS[20])
744
+ local truncated_generation_type = redis.call('TYPE', KEYS[23]).ok
745
+ local truncated_generation = nil
746
+ local truncation_fence_invalid = false
747
+ if truncated then
748
+ if truncated_generation_type == 'string' then
749
+ truncated_generation = redis.call('GET', KEYS[23])
750
+ if truncated_generation == '' then
751
+ truncated_generation = nil
752
+ truncation_fence_invalid = true
753
+ end
754
+ else
755
+ truncation_fence_invalid = true
756
+ end
757
+ elseif truncated_generation_type ~= 'none' then
758
+ truncated_invalid = true
759
+ end
760
+ if stalled_invalid or production_complete_invalid or truncated_invalid then
761
+ invalid_active_state = true
762
+ end
763
+ if truncation_fence_invalid or (truncated and not current_generation) then
764
+ return {0, current_generation or '', -5, {}, {}}
765
+ end
766
+
767
+ local heartbeat_type = redis.call('TYPE', KEYS[5]).ok
768
+ if heartbeat_type ~= 'none' and heartbeat_type ~= 'string' then
769
+ invalid_active_state = true
770
+ end
771
+
772
+ local function retry_snapshot_digest(failed, errors)
773
+ local pieces = {'failures', tostring(#failed)}
774
+ for _, identifier in ipairs(failed) do
775
+ pieces[#pieces + 1] = tostring(string.len(identifier))
776
+ pieces[#pieces + 1] = identifier
777
+ end
778
+ pieces[#pieces + 1] = 'errors'
779
+ pieces[#pieces + 1] = tostring(#errors)
780
+ for _, identifier in ipairs(errors) do
781
+ pieces[#pieces + 1] = tostring(string.len(identifier))
782
+ pieces[#pieces + 1] = identifier
783
+ end
784
+ return redis.sha1hex(table.concat(pieces, string.char(0)))
785
+ end
786
+
787
+ local function read_safe_integer(key)
788
+ if redis.call('TYPE', key).ok ~= 'string' then
789
+ return nil
790
+ end
791
+ local validation = redis.pcall('INCRBY', key, 0)
792
+ if type(validation) == 'table' and validation.err then
793
+ return nil
794
+ end
795
+ local value = tonumber(validation)
796
+ if not value or value < 0 or value > max_safe_integer then
797
+ return nil
798
+ end
799
+ return value
800
+ end
801
+
802
+ local required_stats = {}
803
+ for key_index = 7, 16 do
804
+ required_stats[#required_stats + 1] = KEYS[key_index]
805
+ end
806
+ local existing_stat_count = redis.call('EXISTS', unpack(required_stats))
807
+ local stat_values = {}
808
+ if existing_stat_count == 10 then
809
+ for key_index = 7, 16 do
810
+ local value = read_safe_integer(KEYS[key_index])
811
+ if value == nil then
812
+ invalid_active_state = true
813
+ else
814
+ stat_values[#stat_values + 1] = value
815
+ end
816
+ end
817
+ if not invalid_active_state then
818
+ local runs = stat_values[1]
819
+ local reported = stat_values[3] + stat_values[4] + stat_values[5] + stat_values[6]
820
+ if stat_values[9] > stat_values[10] or runs < stat_values[7] + stat_values[8] or
821
+ runs - stat_values[7] - stat_values[8] ~= reported then
822
+ invalid_active_state = true
823
+ end
824
+ end
825
+ elseif existing_stat_count > 0 or production_complete then
826
+ invalid_active_state = true
827
+ end
828
+ if not retained_ttl and (stream_exists or current_generation or existing_stat_count > 0) then
829
+ invalid_active_state = true
830
+ end
831
+ if existing_stat_count > 0 and not current_generation then
832
+ invalid_active_state = true
833
+ end
834
+
835
+ -- Active pre-production attempts are safe to join only after all
836
+ -- retained state passes validation. Terminal attempts fall through
837
+ -- to grace handling and fenced retry takeover below.
838
+ if stream_exists and current_generation and not stalled and not invalid_active_state then
839
+ local terminal_complete = production_complete and existing_stat_count == 10 and
840
+ stat_values[9] == stat_values[10]
841
+ if truncated and truncated_generation ~= current_generation then
842
+ if requested_ttl ~= retained_ttl then
843
+ return {0, current_generation, -4, {}, {}}
844
+ end
845
+ -- A mode-3 replacement generation is already active. Join it as
846
+ -- an explicitly aborted follower instead of replacing its token.
847
+ return {0, current_generation, -3, {}, {}}
848
+ elseif not truncated and not terminal_complete then
849
+ if requested_ttl ~= retained_ttl then
850
+ return {0, current_generation, -4, {}, {}}
851
+ end
852
+ return {0, current_generation, -1, {}, {}}
853
+ end
854
+ end
855
+
856
+ local mode = 0 -- new run
857
+ local previous_failures = {}
858
+ local previous_errors = {}
859
+
860
+ if stalled or invalid_active_state then
861
+ mode = 2 -- fail-closed full rerun
862
+ elseif existing_stat_count == 10 then
863
+ local failures = stat_values[4]
864
+ local errors = stat_values[5]
865
+ local acks = stat_values[9]
866
+ local size = stat_values[10]
867
+
868
+ if truncated then
869
+ mode = 3 -- intentionally aborted at max_failures
870
+ elseif acks == size and production_complete then
871
+ local retry_set_type = redis.call('TYPE', KEYS[6]).ok
872
+ local skipped_list_type = redis.call('TYPE', KEYS[17]).ok
873
+ local failed_list_type = redis.call('TYPE', KEYS[18]).ok
874
+ local error_list_type = redis.call('TYPE', KEYS[19]).ok
875
+ local digest_type = redis.call('TYPE', KEYS[22]).ok
876
+ if (retry_set_type ~= 'none' and retry_set_type ~= 'set') or
877
+ (skipped_list_type ~= 'none' and skipped_list_type ~= 'list') or
878
+ (failed_list_type ~= 'none' and failed_list_type ~= 'list') or
879
+ (error_list_type ~= 'none' and error_list_type ~= 'list') or digest_type ~= 'string' then
880
+ mode = 2
881
+ else
882
+ previous_failures = redis.call('LRANGE', KEYS[18], 0, -1)
883
+ previous_errors = redis.call('LRANGE', KEYS[19], 0, -1)
884
+ local expected_digest = redis.call('GET', KEYS[22])
885
+ local actual_digest = retry_snapshot_digest(previous_failures, previous_errors)
886
+ if #previous_failures ~= failures or #previous_errors ~= errors or expected_digest ~= actual_digest then
887
+ mode = 2
888
+ else
889
+ mode = 1 -- valid selective retry
890
+ end
891
+ end
892
+ else
893
+ mode = 2
894
+ end
895
+ elseif existing_stat_count > 0 then
896
+ mode = 2
897
+ else
898
+ -- Auxiliary state without statistics is an abandoned/corrupt run,
899
+ -- not a genuinely new run ID. The generation token is excluded
900
+ -- because it intentionally survives cleanup until the shared TTL.
901
+ local auxiliary_count = redis.call(
902
+ 'EXISTS', KEYS[3], KEYS[4], KEYS[5], KEYS[6], KEYS[17], KEYS[18], KEYS[19], KEYS[20], KEYS[21], KEYS[22], KEYS[23], KEYS[24]
903
+ )
904
+ if auxiliary_count > 0 then
905
+ mode = 2
906
+ end
907
+ end
908
+
909
+ -- A completed attempt must remain fenced for the full grace period,
910
+ -- whether or not its stream survived cleanup. Wait and re-register
911
+ -- rather than joining a terminal generation with no retry work.
912
+ if mode == 1 and current_generation then
913
+ local completed_at = nil
914
+ if redis.call('TYPE', KEYS[21]).ok == 'string' then
915
+ completed_at = tonumber(redis.call('GET', KEYS[21]))
916
+ end
917
+ if not completed_at then
918
+ mode = 2
919
+ elseif ARGV[6] ~= current_generation then
920
+ local redis_time = redis.call('TIME')
921
+ local now = tonumber(redis_time[1]) + tonumber(redis_time[2]) / 1000000
922
+ local grace = tonumber(ARGV[5])
923
+ local remaining_grace = grace - (now - completed_at)
924
+ if remaining_grace > grace then
925
+ remaining_grace = grace
926
+ end
927
+ if remaining_grace > 0 then
928
+ -- The terminal snapshot may have been written with a shorter
929
+ -- TTL than this retry invocation requests. Refresh every
930
+ -- validated retained key before sleeping so the snapshot
931
+ -- survives the full current grace period and safety margin.
932
+ redis.call('SET', KEYS[24], requested_ttl, 'EX', requested_ttl)
933
+ for key_index = 1, #KEYS do
934
+ if redis.call('EXISTS', KEYS[key_index]) == 1 then
935
+ redis.call('EXPIRE', KEYS[key_index], ARGV[2])
936
+ end
937
+ end
938
+ return {0, current_generation, -2, {}, {}, tostring(remaining_grace)}
939
+ end
940
+ end
941
+ end
942
+
943
+ -- No active attempt reaches here. A random token never repeats after
944
+ -- expiry/eviction, so old workers cannot regain authority over a new
945
+ -- attempt. All destructive changes and group creation are atomic.
946
+ redis.call('DEL', KEYS[1])
947
+ if mode == 2 then
948
+ for key_index = 3, 23 do
949
+ redis.call('DEL', KEYS[key_index])
950
+ end
951
+ previous_failures = {}
952
+ previous_errors = {}
953
+ else
954
+ redis.call('DEL', KEYS[4], KEYS[5], KEYS[6], KEYS[21], KEYS[22])
955
+ if mode == 1 then
956
+ redis.call('SET', KEYS[15], 0, 'EX', ARGV[2])
957
+ redis.call('SET', KEYS[16], 0, 'EX', ARGV[2])
958
+ elseif mode ~= 3 then
959
+ redis.call('DEL', KEYS[15], KEYS[16])
960
+ end
961
+ if mode == 3 then
962
+ redis.call('SET', KEYS[20], 1, 'EX', ARGV[2])
963
+ end
964
+ end
965
+
966
+ local generation = ARGV[4]
967
+ redis.call('SET', KEYS[2], generation, 'EX', ARGV[2])
968
+ redis.call('SET', KEYS[24], requested_ttl, 'EX', requested_ttl)
969
+ if mode ~= 3 then
970
+ local group_name = ARGV[1] .. '-' .. generation
971
+ redis.call('XGROUP', 'CREATE', KEYS[1], group_name, '0', 'MKSTREAM')
972
+ redis.call('SET', KEYS[5], 0, 'EX', ARGV[2])
973
+ end
974
+ -- Retained state may come from an attempt with a shorter TTL. Align
975
+ -- every surviving key with this generation before returning so a
976
+ -- mode-3 follower cannot lose its truncation discriminator between
977
+ -- registration and result adjustment.
978
+ for key_index = 1, #KEYS do
979
+ if redis.call('EXISTS', KEYS[key_index]) == 1 then
980
+ redis.call('EXPIRE', KEYS[key_index], ARGV[2])
981
+ end
982
+ end
983
+ return {1, generation, mode, previous_failures, previous_errors}
984
+ LUA
985
+ end
986
+
987
+ sig { returns(String) }
988
+ def reset_results_script
989
+ @reset_results_script ||= redis.script(:load, <<~LUA)
990
+ -- KEYS match adjust_results_script: generation, stream, retry_set,
991
+ -- control markers, ten statistics, three result lists, completed_at,
992
+ -- retry_snapshot_digest, truncated_generation, and retention_ttl.
993
+ if redis.call('TYPE', KEYS[1]).ok ~= 'string' or redis.call('GET', KEYS[1]) ~= ARGV[1] then
994
+ return redis.error_reply('STALEATTEMPT missing or mismatched generation')
995
+ elseif redis.call('TYPE', KEYS[2]).ok ~= 'stream' then
996
+ return redis.error_reply('COORDINATORSTREAM missing or invalid stream')
997
+ end
998
+ if redis.call('TYPE', KEYS[24]).ok ~= 'string' then
999
+ return redis.error_reply('COORDINATORSTATE missing retention TTL')
1000
+ end
1001
+ local retention_validation = redis.pcall('INCRBY', KEYS[24], 0)
1002
+ local effective_ttl = tonumber(retention_validation)
1003
+ if (type(retention_validation) == 'table' and retention_validation.err) or
1004
+ not effective_ttl or tonumber(ARGV[2]) > effective_ttl then
1005
+ return redis.error_reply('COORDINATORCONFIG mismatched retention TTL')
1006
+ end
1007
+
1008
+ local size = tonumber(ARGV[3])
1009
+ if not size or size < 0 or size % 1 ~= 0 or size > 9007199254740991 then
1010
+ return redis.error_reply('COORDINATORSTATE unsafe full-rerun size')
1011
+ end
1012
+
1013
+ redis.call('DEL', KEYS[3], KEYS[4], KEYS[6], KEYS[7])
1014
+ for key_index = 8, 23 do
1015
+ redis.call('DEL', KEYS[key_index])
1016
+ end
1017
+ local reply = {}
1018
+ for stat_index = 1, 10 do
1019
+ local value = stat_index == 10 and size or 0
1020
+ redis.call('SET', KEYS[stat_index + 7], value, 'EX', effective_ttl)
1021
+ reply[stat_index] = value
1022
+ end
1023
+ redis.call('EXPIRE', KEYS[1], effective_ttl)
1024
+ redis.call('EXPIRE', KEYS[2], effective_ttl)
1025
+ redis.call('EXPIRE', KEYS[5], effective_ttl)
1026
+ redis.call('EXPIRE', KEYS[24], effective_ttl)
1027
+ return reply
1028
+ LUA
1029
+ end
1030
+
1031
+ sig { returns(String) }
1032
+ def commit_results_script
1033
+ @commit_results_script ||= redis.script(:load, <<~LUA)
1034
+ local result_count = tonumber(ARGV[3])
1035
+ local expected_generation = ARGV[4]
1036
+ local argument_index = 5
1037
+ local commit_results = {}
1038
+ -- runs, assertions, passes, failures, errors, skips, requeues,
1039
+ -- discards, acks, size
1040
+ local deltas = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
1041
+
1042
+ local function retry_snapshot_digest(failed_key, error_key)
1043
+ local failed = redis.call('LRANGE', failed_key, 0, -1)
1044
+ local errors = redis.call('LRANGE', error_key, 0, -1)
1045
+ local pieces = {'failures', tostring(#failed)}
1046
+ for _, identifier in ipairs(failed) do
1047
+ pieces[#pieces + 1] = tostring(string.len(identifier))
1048
+ pieces[#pieces + 1] = identifier
1049
+ end
1050
+ pieces[#pieces + 1] = 'errors'
1051
+ pieces[#pieces + 1] = tostring(#errors)
1052
+ for _, identifier in ipairs(errors) do
1053
+ pieces[#pieces + 1] = tostring(string.len(identifier))
1054
+ pieces[#pieces + 1] = identifier
1055
+ end
1056
+ return redis.sha1hex(table.concat(pieces, string.char(0)))
1057
+ end
1058
+
1059
+ -- Every statistics key is created before any stream entries are
1060
+ -- published. Refuse to recreate missing state as zero: doing so can
1061
+ -- make an incomplete run look complete when both acks and size become
1062
+ -- 0. This preflight occurs before any write because Redis does not
1063
+ -- roll back earlier script writes when a later command errors.
1064
+ if redis.call('TYPE', KEYS[19]).ok ~= 'string' then
1065
+ return redis.error_reply('COORDINATORSTATE missing or invalid key ' .. KEYS[19])
1066
+ end
1067
+ local current_generation = redis.call('GET', KEYS[19])
1068
+ if not current_generation then
1069
+ return redis.error_reply('COORDINATORSTATE missing required key ' .. KEYS[19])
1070
+ elseif current_generation ~= expected_generation then
1071
+ return redis.error_reply('STALEATTEMPT expected generation ' .. expected_generation)
1072
+ end
1073
+ if redis.call('TYPE', KEYS[24]).ok ~= 'string' then
1074
+ return redis.error_reply('COORDINATORSTATE missing retention TTL')
1075
+ end
1076
+ local retention_validation = redis.pcall('INCRBY', KEYS[24], 0)
1077
+ local effective_ttl = tonumber(retention_validation)
1078
+ if (type(retention_validation) == 'table' and retention_validation.err) or
1079
+ not effective_ttl or tonumber(ARGV[2]) > effective_ttl then
1080
+ return redis.error_reply('COORDINATORCONFIG mismatched retention TTL')
1081
+ end
1082
+
1083
+ if redis.call('TYPE', KEYS[1]).ok ~= 'stream' then
1084
+ return redis.error_reply('COORDINATORSTREAM missing or invalid key ' .. KEYS[1])
1085
+ end
1086
+ local groups = redis.pcall('XINFO', 'GROUPS', KEYS[1])
1087
+ local group_exists = false
1088
+ if not groups.err then
1089
+ for _, group in ipairs(groups) do
1090
+ for field_index = 1, #group, 2 do
1091
+ if group[field_index] == 'name' and group[field_index + 1] == ARGV[1] then
1092
+ group_exists = true
1093
+ end
1094
+ end
1095
+ end
1096
+ end
1097
+ if not group_exists then
1098
+ return redis.error_reply('COORDINATORSTREAM missing consumer group ' .. ARGV[1])
1099
+ end
1100
+
1101
+ local retry_type = redis.call('TYPE', KEYS[2]).ok
1102
+ if retry_type ~= 'none' and retry_type ~= 'set' then
1103
+ return redis.error_reply('COORDINATORSTATE invalid retry set type')
1104
+ end
1105
+ for list_index = 13, 15 do
1106
+ local list_type = redis.call('TYPE', KEYS[list_index]).ok
1107
+ if list_type ~= 'none' and list_type ~= 'list' then
1108
+ return redis.error_reply('COORDINATORSTATE invalid result list type')
1109
+ end
1110
+ end
1111
+ local production_type = redis.call('TYPE', KEYS[16]).ok
1112
+ local production_complete = false
1113
+ if production_type == 'string' then
1114
+ if redis.call('GET', KEYS[16]) ~= '1' then
1115
+ return redis.error_reply('COORDINATORSTATE invalid production marker')
1116
+ end
1117
+ production_complete = true
1118
+ elseif production_type ~= 'none' then
1119
+ return redis.error_reply('COORDINATORSTATE invalid production marker type')
1120
+ end
1121
+ local digest_type = redis.call('TYPE', KEYS[22]).ok
1122
+ if digest_type ~= 'none' and digest_type ~= 'string' then
1123
+ return redis.error_reply('COORDINATORSTATE invalid retry snapshot digest type')
1124
+ end
1125
+
1126
+ local max_safe_integer = 9007199254740991
1127
+ local stat_values = {}
1128
+ for stat_index = 1, 10 do
1129
+ local stat_key = KEYS[stat_index + 2]
1130
+ if redis.call('TYPE', stat_key).ok ~= 'string' then
1131
+ return redis.error_reply('COORDINATORSTATE missing or invalid key ' .. stat_key)
1132
+ end
1133
+ local validation = redis.pcall('INCRBY', stat_key, 0)
1134
+ if type(validation) == 'table' and validation.err then
1135
+ return redis.error_reply('COORDINATORSTATE invalid required key ' .. stat_key)
1136
+ end
1137
+ local value = tonumber(validation)
1138
+ if value < 0 or value > max_safe_integer then
1139
+ return redis.error_reply('COORDINATORSTATE unsafe statistic value ' .. stat_key)
1140
+ end
1141
+ stat_values[stat_index] = value
1142
+ end
1143
+ local runs = stat_values[1]
1144
+ local reported = stat_values[3] + stat_values[4] + stat_values[5] + stat_values[6]
1145
+ if stat_values[9] > stat_values[10] or runs < stat_values[7] + stat_values[8] or
1146
+ runs - stat_values[7] - stat_values[8] ~= reported then
1147
+ return redis.error_reply('COORDINATORSTATE inconsistent aggregate statistics')
1148
+ end
1149
+
1150
+ local truncated_type = redis.call('TYPE', KEYS[20]).ok
1151
+ local truncated_generation_type = redis.call('TYPE', KEYS[23]).ok
1152
+ local truncated = false
1153
+ if truncated_type == 'string' then
1154
+ if redis.call('GET', KEYS[20]) ~= '1' or truncated_generation_type ~= 'string' then
1155
+ return redis.error_reply('COORDINATORSTATE invalid truncated marker')
1156
+ end
1157
+ truncated = true
1158
+ elseif truncated_type ~= 'none' or truncated_generation_type ~= 'none' then
1159
+ return redis.error_reply('COORDINATORSTATE invalid truncated marker type')
1160
+ end
1161
+ if truncated then
1162
+ local truncated_reply = {}
1163
+ for result_index = 1, result_count do
1164
+ truncated_reply[result_index] = 0
1165
+ end
1166
+ for stat_index = 1, 10 do
1167
+ truncated_reply[result_count + stat_index] = stat_values[stat_index]
1168
+ end
1169
+ truncated_reply[result_count + 11] = production_complete and 1 or 0
1170
+ truncated_reply[result_count + 12] = 1
1171
+ for key_index = 1, #KEYS do
1172
+ redis.call('EXPIRE', KEYS[key_index], effective_ttl)
1173
+ end
1174
+ return truncated_reply
1175
+ end
1176
+
1177
+ local allowed_result_types = {
1178
+ passed = true, failed = true, error = true, skipped = true,
1179
+ discarded = true, requeued = true
1180
+ }
1181
+ local validation_index = argument_index
1182
+ local assertion_total = 0
1183
+ for _ = 1, result_count do
1184
+ local result_type = ARGV[validation_index + 1]
1185
+ local assertions = tonumber(ARGV[validation_index + 4])
1186
+ if not allowed_result_types[result_type] or not assertions or assertions < 0 or assertions % 1 ~= 0 then
1187
+ return redis.error_reply('COORDINATORSTATE invalid result payload')
1188
+ end
1189
+ assertion_total = assertion_total + assertions
1190
+ validation_index = validation_index + 5
1191
+ end
1192
+ local maximum_deltas = {
1193
+ result_count, assertion_total, result_count, result_count, result_count,
1194
+ result_count, result_count, result_count, result_count, 0
1195
+ }
1196
+ for stat_index = 1, 10 do
1197
+ if math.abs(stat_values[stat_index]) + math.abs(maximum_deltas[stat_index]) > max_safe_integer then
1198
+ return redis.error_reply('COORDINATORSTATE unsafe statistic delta')
1199
+ end
1200
+ end
1201
+
1202
+ for result_index = 1, result_count do
1203
+ local entry_id = ARGV[argument_index]
1204
+ local result_type = ARGV[argument_index + 1]
1205
+ local attempt_id = ARGV[argument_index + 2]
1206
+ local identifier = ARGV[argument_index + 3]
1207
+ local assertions = tonumber(ARGV[argument_index + 4])
1208
+ argument_index = argument_index + 5
1209
+
1210
+ local committed
1211
+ if result_type == 'requeued' then
1212
+ committed = redis.call('SADD', KEYS[2], attempt_id)
1213
+ deltas[7] = deltas[7] + 1
1214
+ else
1215
+ committed = redis.call('XACK', KEYS[1], ARGV[1], entry_id)
1216
+ if committed == 1 then
1217
+ deltas[9] = deltas[9] + 1
1218
+ if result_type == 'passed' then
1219
+ deltas[3] = deltas[3] + 1
1220
+ elseif result_type == 'failed' then
1221
+ deltas[4] = deltas[4] + 1
1222
+ redis.call('LPUSH', KEYS[14], identifier)
1223
+ elseif result_type == 'error' then
1224
+ deltas[5] = deltas[5] + 1
1225
+ redis.call('LPUSH', KEYS[15], identifier)
1226
+ elseif result_type == 'skipped' then
1227
+ deltas[6] = deltas[6] + 1
1228
+ redis.call('LPUSH', KEYS[13], identifier)
1229
+ elseif result_type == 'discarded' then
1230
+ deltas[8] = deltas[8] + 1
1231
+ end
1232
+ else
1233
+ -- Another worker already ACKed this entry, so the local result
1234
+ -- will be reported as discarded.
1235
+ deltas[8] = deltas[8] + 1
1236
+ end
1237
+ end
1238
+
1239
+ deltas[1] = deltas[1] + 1
1240
+ deltas[2] = deltas[2] + assertions
1241
+ commit_results[result_index] = committed
1242
+ end
1243
+
1244
+ local reply = commit_results
1245
+ for stat_index = 1, 10 do
1246
+ reply[result_count + stat_index] = redis.call('INCRBY', KEYS[stat_index + 2], deltas[stat_index])
1247
+ end
1248
+
1249
+ local updated_acks = reply[result_count + 9]
1250
+ local updated_size = reply[result_count + 10]
1251
+ if updated_acks == updated_size and production_complete then
1252
+ local redis_time = redis.call('TIME')
1253
+ local completed_at = tonumber(redis_time[1]) + tonumber(redis_time[2]) / 1000000
1254
+ redis.call('SET', KEYS[21], completed_at, 'EX', effective_ttl)
1255
+ local digest = retry_snapshot_digest(KEYS[14], KEYS[15])
1256
+ redis.call('SET', KEYS[22], digest, 'EX', effective_ttl)
1257
+ end
1258
+ reply[result_count + 11] = production_complete and 1 or 0
1259
+ reply[result_count + 12] = 0
1260
+
1261
+ for key_index = 1, #KEYS do
1262
+ redis.call('EXPIRE', KEYS[key_index], effective_ttl)
1263
+ end
1264
+
1265
+ return reply
1266
+ LUA
1267
+ end
1268
+
1269
+ sig { returns(String) }
1270
+ def adjust_results_script
1271
+ @adjust_results_script ||= redis.script(:load, <<~LUA)
1272
+ if redis.call('TYPE', KEYS[1]).ok ~= 'string' then
1273
+ return redis.error_reply('COORDINATORSTATE missing or invalid key ' .. KEYS[1])
1274
+ end
1275
+ local current_generation = redis.call('GET', KEYS[1])
1276
+ if not current_generation then
1277
+ return redis.error_reply('COORDINATORSTATE missing required key ' .. KEYS[1])
1278
+ elseif current_generation ~= ARGV[1] then
1279
+ return redis.error_reply('STALEATTEMPT expected generation ' .. ARGV[1])
1280
+ end
1281
+ if redis.call('TYPE', KEYS[24]).ok ~= 'string' then
1282
+ return redis.error_reply('COORDINATORSTATE missing retention TTL')
1283
+ end
1284
+ local retention_validation = redis.pcall('INCRBY', KEYS[24], 0)
1285
+ local effective_ttl = tonumber(retention_validation)
1286
+ if (type(retention_validation) == 'table' and retention_validation.err) or
1287
+ not effective_ttl or tonumber(ARGV[2]) > effective_ttl then
1288
+ return redis.error_reply('COORDINATORCONFIG mismatched retention TTL')
1289
+ end
1290
+
1291
+ local stream_type = redis.call('TYPE', KEYS[2]).ok
1292
+ local retry_type = redis.call('TYPE', KEYS[3]).ok
1293
+ if stream_type ~= 'stream' or (retry_type ~= 'none' and retry_type ~= 'set') then
1294
+ return redis.error_reply('COORDINATORSTATE invalid run key type')
1295
+ end
1296
+ for list_index = 18, 20 do
1297
+ local list_type = redis.call('TYPE', KEYS[list_index]).ok
1298
+ if list_type ~= 'none' and list_type ~= 'list' then
1299
+ return redis.error_reply('COORDINATORSTATE invalid result list type')
1300
+ end
1301
+ end
1302
+ local digest_type = redis.call('TYPE', KEYS[22]).ok
1303
+ if digest_type ~= 'none' and digest_type ~= 'string' then
1304
+ return redis.error_reply('COORDINATORSTATE invalid retry snapshot digest type')
1305
+ end
1306
+ local truncated_type = redis.call('TYPE', KEYS[7]).ok
1307
+ local truncated_generation_type = redis.call('TYPE', KEYS[23]).ok
1308
+ if truncated_type == 'string' then
1309
+ if redis.call('GET', KEYS[7]) ~= '1' or truncated_generation_type ~= 'string' then
1310
+ return redis.error_reply('COORDINATORSTATE invalid truncated state')
1311
+ end
1312
+ elseif truncated_type ~= 'none' or truncated_generation_type ~= 'none' then
1313
+ return redis.error_reply('COORDINATORSTATE invalid truncated state type')
1314
+ end
1315
+
1316
+ local max_safe_integer = 9007199254740991
1317
+ for stat_index = 1, 10 do
1318
+ local stat_key = KEYS[stat_index + 7]
1319
+ local stat_type = redis.call('TYPE', stat_key).ok
1320
+ if stat_type ~= 'none' and stat_type ~= 'string' then
1321
+ return redis.error_reply('COORDINATORSTATE invalid required key ' .. stat_key)
1322
+ elseif stat_type == 'none' and ARGV[4] ~= '1' then
1323
+ return redis.error_reply('COORDINATORSTATE missing required key ' .. stat_key)
1324
+ end
1325
+ local current_value = 0
1326
+ if stat_type == 'string' then
1327
+ local validation = redis.pcall('INCRBY', stat_key, 0)
1328
+ if type(validation) == 'table' and validation.err then
1329
+ return redis.error_reply('COORDINATORSTATE invalid required key ' .. stat_key)
1330
+ end
1331
+ current_value = tonumber(validation)
1332
+ end
1333
+ local delta = tonumber(ARGV[stat_index + 4])
1334
+ local updated_value = delta and current_value + delta or nil
1335
+ if not delta or delta % 1 ~= 0 or updated_value < 0 or updated_value > max_safe_integer then
1336
+ return redis.error_reply('COORDINATORSTATE unsafe statistic value ' .. stat_key)
1337
+ end
1338
+ end
1339
+
1340
+ if ARGV[3] == '1' then
1341
+ redis.call('DEL', KEYS[19], KEYS[20])
1342
+ end
1343
+ redis.call('DEL', KEYS[22])
1344
+ local reply = {}
1345
+ for stat_index = 1, 10 do
1346
+ reply[stat_index] = redis.call('INCRBY', KEYS[stat_index + 7], ARGV[stat_index + 4])
1347
+ end
1348
+ for key_index = 1, #KEYS do
1349
+ redis.call('EXPIRE', KEYS[key_index], effective_ttl)
1350
+ end
1351
+ return reply
1352
+ LUA
1353
+ end
1354
+
1355
+ sig { returns(String) }
1356
+ def publish_tests_script
1357
+ @publish_tests_script ||= redis.script(:load, <<~LUA)
1358
+ if redis.call('TYPE', KEYS[1]).ok ~= 'string' then
1359
+ return redis.error_reply('COORDINATORSTATE missing or invalid key ' .. KEYS[1])
1360
+ end
1361
+ local current_generation = redis.call('GET', KEYS[1])
1362
+ if not current_generation then
1363
+ return redis.error_reply('COORDINATORSTATE missing required key ' .. KEYS[1])
1364
+ elseif current_generation ~= ARGV[1] then
1365
+ return redis.error_reply('STALEATTEMPT expected generation ' .. ARGV[1])
1366
+ elseif redis.call('TYPE', KEYS[2]).ok ~= 'stream' then
1367
+ return redis.error_reply('COORDINATORSTREAM missing or invalid key ' .. KEYS[2])
1368
+ end
1369
+ if redis.call('TYPE', KEYS[11]).ok ~= 'string' then
1370
+ return redis.error_reply('COORDINATORSTATE missing retention TTL')
1371
+ end
1372
+ local retention_validation = redis.pcall('INCRBY', KEYS[11], 0)
1373
+ local effective_ttl = tonumber(retention_validation)
1374
+ if (type(retention_validation) == 'table' and retention_validation.err) or
1375
+ not effective_ttl or tonumber(ARGV[2]) > effective_ttl then
1376
+ return redis.error_reply('COORDINATORCONFIG mismatched retention TTL')
1377
+ end
1378
+
1379
+ local groups = redis.pcall('XINFO', 'GROUPS', KEYS[2])
1380
+ local group_exists = false
1381
+ if not groups.err then
1382
+ for _, group in ipairs(groups) do
1383
+ for field_index = 1, #group, 2 do
1384
+ if group[field_index] == 'name' and group[field_index + 1] == ARGV[5] then
1385
+ group_exists = true
1386
+ end
1387
+ end
1388
+ end
1389
+ end
1390
+ if not group_exists then
1391
+ return redis.error_reply('COORDINATORSTREAM missing consumer group ' .. ARGV[5])
1392
+ end
1393
+ for list_index = 8, 9 do
1394
+ local list_type = redis.call('TYPE', KEYS[list_index]).ok
1395
+ if list_type ~= 'none' and list_type ~= 'list' then
1396
+ return redis.error_reply('COORDINATORSTATE invalid retry result list type')
1397
+ end
1398
+ end
1399
+ local digest_type = redis.call('TYPE', KEYS[10]).ok
1400
+ if digest_type ~= 'none' and digest_type ~= 'string' then
1401
+ return redis.error_reply('COORDINATORSTATE invalid retry snapshot digest type')
1402
+ end
1403
+
1404
+ local function retry_snapshot_digest(failed_key, error_key)
1405
+ local failed = redis.call('LRANGE', failed_key, 0, -1)
1406
+ local errors = redis.call('LRANGE', error_key, 0, -1)
1407
+ local pieces = {'failures', tostring(#failed)}
1408
+ for _, identifier in ipairs(failed) do
1409
+ pieces[#pieces + 1] = tostring(string.len(identifier))
1410
+ pieces[#pieces + 1] = identifier
1411
+ end
1412
+ pieces[#pieces + 1] = 'errors'
1413
+ pieces[#pieces + 1] = tostring(#errors)
1414
+ for _, identifier in ipairs(errors) do
1415
+ pieces[#pieces + 1] = tostring(string.len(identifier))
1416
+ pieces[#pieces + 1] = identifier
1417
+ end
1418
+ return redis.sha1hex(table.concat(pieces, string.char(0)))
1419
+ end
1420
+
1421
+ local max_safe_integer = 9007199254740991
1422
+ local function read_safe_counter(key)
1423
+ if redis.call('TYPE', key).ok ~= 'string' then
1424
+ return nil
1425
+ end
1426
+ local validation = redis.pcall('INCRBY', key, 0)
1427
+ if type(validation) == 'table' and validation.err then
1428
+ return nil
1429
+ end
1430
+ local value = tonumber(validation)
1431
+ if not value or value < 0 or value > max_safe_integer then
1432
+ return nil
1433
+ end
1434
+ return value
1435
+ end
1436
+
1437
+ local acks = read_safe_counter(KEYS[5])
1438
+ local size = read_safe_counter(KEYS[6])
1439
+ if not acks or not size or acks > size then
1440
+ return redis.error_reply('COORDINATORSTATE invalid completion counters')
1441
+ end
1442
+
1443
+ local test_count = tonumber(ARGV[4])
1444
+ local argument_index = 6
1445
+ for _ = 1, test_count do
1446
+ redis.call(
1447
+ 'XADD', KEYS[2], '*',
1448
+ 'class_name', ARGV[argument_index],
1449
+ 'method_name', ARGV[argument_index + 1]
1450
+ )
1451
+ argument_index = argument_index + 2
1452
+ end
1453
+ if ARGV[3] == '1' then
1454
+ redis.call('SET', KEYS[3], 1, 'EX', effective_ttl)
1455
+ if acks == size then
1456
+ local redis_time = redis.call('TIME')
1457
+ local completed_at = tonumber(redis_time[1]) + tonumber(redis_time[2]) / 1000000
1458
+ redis.call('SET', KEYS[7], completed_at, 'EX', effective_ttl)
1459
+ local digest = retry_snapshot_digest(KEYS[8], KEYS[9])
1460
+ redis.call('SET', KEYS[10], digest, 'EX', effective_ttl)
1461
+ end
1462
+ end
1463
+ for key_index = 1, #KEYS do
1464
+ redis.call('EXPIRE', KEYS[key_index], effective_ttl)
1465
+ end
1466
+ return test_count
1467
+ LUA
1468
+ end
1469
+
1470
+ sig { returns(String) }
1471
+ def heartbeat_script
1472
+ @heartbeat_script ||= redis.script(:load, <<~LUA)
1473
+ local generation_type = redis.call('TYPE', KEYS[1]).ok
1474
+ if generation_type == 'none' then
1475
+ return 0
1476
+ elseif generation_type ~= 'string' then
1477
+ return redis.error_reply('COORDINATORSTATE invalid key type ' .. KEYS[1])
1478
+ end
1479
+ local current_generation = redis.call('GET', KEYS[1])
1480
+ if not current_generation or current_generation ~= ARGV[1] then
1481
+ return 0
1482
+ end
1483
+ if redis.call('TYPE', KEYS[3]).ok ~= 'string' then
1484
+ return redis.error_reply('COORDINATORSTATE missing retention TTL')
1485
+ end
1486
+ local retention_validation = redis.pcall('INCRBY', KEYS[3], 0)
1487
+ local effective_ttl = tonumber(retention_validation)
1488
+ if (type(retention_validation) == 'table' and retention_validation.err) or
1489
+ not effective_ttl or tonumber(ARGV[2]) > effective_ttl then
1490
+ return redis.error_reply('COORDINATORCONFIG mismatched retention TTL')
1491
+ end
1492
+ local heartbeat_type = redis.call('TYPE', KEYS[2]).ok
1493
+ if heartbeat_type ~= 'none' and heartbeat_type ~= 'string' then
1494
+ return redis.error_reply('COORDINATORSTATE invalid key type ' .. KEYS[2])
1495
+ end
1496
+ redis.call('INCR', KEYS[2])
1497
+ redis.call('EXPIRE', KEYS[1], effective_ttl)
1498
+ redis.call('EXPIRE', KEYS[2], effective_ttl)
1499
+ redis.call('EXPIRE', KEYS[3], effective_ttl)
1500
+ return 1
315
1501
  LUA
316
1502
  end
317
1503
 
1504
+ sig { returns(String) }
1505
+ def mark_attempt_state_script
1506
+ @mark_attempt_state_script ||= redis.script(:load, <<~LUA)
1507
+ if redis.call('TYPE', KEYS[1]).ok ~= 'string' then
1508
+ return 0
1509
+ end
1510
+ local current_generation = redis.call('GET', KEYS[1])
1511
+ if not current_generation or current_generation ~= ARGV[1] then
1512
+ return 0
1513
+ end
1514
+ if redis.call('TYPE', KEYS[7]).ok ~= 'string' then
1515
+ return redis.error_reply('COORDINATORSTATE missing retention TTL')
1516
+ end
1517
+ local retention_validation = redis.pcall('INCRBY', KEYS[7], 0)
1518
+ local effective_ttl = tonumber(retention_validation)
1519
+ if (type(retention_validation) == 'table' and retention_validation.err) or
1520
+ not effective_ttl or tonumber(ARGV[2]) > effective_ttl then
1521
+ return redis.error_reply('COORDINATORCONFIG mismatched retention TTL')
1522
+ end
1523
+
1524
+ local production_type = redis.call('TYPE', KEYS[3]).ok
1525
+ if production_type == 'string' then
1526
+ if redis.call('GET', KEYS[3]) ~= '1' then
1527
+ return redis.error_reply('COORDINATORSTATE invalid production marker')
1528
+ end
1529
+ local function read_safe_counter(key)
1530
+ if redis.call('TYPE', key).ok ~= 'string' then
1531
+ return nil
1532
+ end
1533
+ local validation = redis.pcall('INCRBY', key, 0)
1534
+ if type(validation) == 'table' and validation.err then
1535
+ return nil
1536
+ end
1537
+ local value = tonumber(validation)
1538
+ if not value or value < 0 or value > 9007199254740991 then
1539
+ return nil
1540
+ end
1541
+ return value
1542
+ end
1543
+ local acks = read_safe_counter(KEYS[4])
1544
+ local size = read_safe_counter(KEYS[5])
1545
+ if not acks or not size or acks > size then
1546
+ return redis.error_reply('COORDINATORSTATE invalid completion counters')
1547
+ elseif acks == size then
1548
+ return 2
1549
+ end
1550
+ elseif production_type ~= 'none' then
1551
+ return redis.error_reply('COORDINATORSTATE invalid production marker type')
1552
+ end
1553
+
1554
+ redis.call('SET', KEYS[2], 1, 'EX', effective_ttl)
1555
+ redis.call('SET', KEYS[6], current_generation, 'EX', effective_ttl)
1556
+ redis.call('EXPIRE', KEYS[1], effective_ttl)
1557
+ redis.call('EXPIRE', KEYS[7], effective_ttl)
1558
+ return 1
1559
+ LUA
1560
+ end
1561
+
1562
+ sig { returns(String) }
1563
+ def cleanup_script
1564
+ @cleanup_script ||= redis.script(:load, <<~LUA)
1565
+ if redis.call('TYPE', KEYS[2]).ok ~= 'string' then
1566
+ return 0
1567
+ end
1568
+ local current_generation = redis.call('GET', KEYS[2])
1569
+ if not current_generation or current_generation ~= ARGV[1] then
1570
+ return 0
1571
+ end
1572
+ if redis.call('TYPE', KEYS[3]).ok ~= 'string' then
1573
+ return 0
1574
+ end
1575
+ local retention_validation = redis.pcall('INCRBY', KEYS[3], 0)
1576
+ local effective_ttl = tonumber(retention_validation)
1577
+ if (type(retention_validation) == 'table' and retention_validation.err) or
1578
+ not effective_ttl or tonumber(ARGV[3]) > effective_ttl then
1579
+ return 0
1580
+ end
1581
+
1582
+ redis.call('EXPIRE', KEYS[2], effective_ttl)
1583
+ redis.call('EXPIRE', KEYS[3], effective_ttl)
1584
+ redis.pcall('XGROUP', 'DESTROY', KEYS[1], ARGV[2])
1585
+ redis.call('DEL', KEYS[1])
1586
+ return 1
1587
+ LUA
1588
+ end
1589
+
1590
+ sig { returns(String) }
1591
+ def abort_script
1592
+ @abort_script ||= redis.script(:load, <<~LUA)
1593
+ if redis.call('TYPE', KEYS[2]).ok ~= 'string' then
1594
+ return 0
1595
+ end
1596
+ local current_generation = redis.call('GET', KEYS[2])
1597
+ if not current_generation or current_generation ~= ARGV[1] then
1598
+ return 0
1599
+ end
1600
+
1601
+ if redis.call('TYPE', KEYS[4]).ok ~= 'string' then
1602
+ return 0
1603
+ end
1604
+ local retention_validation = redis.pcall('INCRBY', KEYS[4], 0)
1605
+ local effective_ttl = tonumber(retention_validation)
1606
+ if (type(retention_validation) == 'table' and retention_validation.err) or
1607
+ not effective_ttl or tonumber(ARGV[3]) > effective_ttl then
1608
+ return 0
1609
+ end
1610
+
1611
+ redis.call('EXPIRE', KEYS[2], effective_ttl)
1612
+ redis.call('EXPIRE', KEYS[4], effective_ttl)
1613
+ redis.call('SET', KEYS[3], 1, 'EX', effective_ttl)
1614
+ redis.pcall('XGROUP', 'DESTROY', KEYS[1], ARGV[2])
1615
+ redis.call('DEL', KEYS[1])
1616
+ return 1
1617
+ LUA
1618
+ end
1619
+
1620
+ sig { params(script_name: Symbol).returns(String) }
1621
+ def resolve_script(script_name)
1622
+ case script_name
1623
+ when :register_consumergroup then register_consumergroup_script
1624
+ when :read_results then read_results_script
1625
+ when :reset_results then reset_results_script
1626
+ when :commit_results then commit_results_script
1627
+ when :cleanup then cleanup_script
1628
+ when :abort then abort_script
1629
+ when :adjust_results then adjust_results_script
1630
+ when :publish_tests then publish_tests_script
1631
+ when :heartbeat then heartbeat_script
1632
+ when :mark_attempt_state then mark_attempt_state_script
1633
+ else raise ArgumentError, "Unknown Redis script: #{script_name}"
1634
+ end
1635
+ end
1636
+
1637
+ sig do
1638
+ params(
1639
+ script_name: Symbol,
1640
+ keys: T::Array[String],
1641
+ argv: T::Array[T.untyped],
1642
+ ).returns(T.untyped)
1643
+ end
1644
+ def execute_script(script_name:, keys:, argv:)
1645
+ attempts = T.let(0, Integer)
1646
+ begin
1647
+ if script_name == :read_results
1648
+ redis.evalsha(resolve_script(script_name), keys: keys, argv: argv)
1649
+ else
1650
+ # Script loading and EVALSHA share one serialized no-reconnect
1651
+ # scope. redis-rb toggles reconnect state outside its command
1652
+ # monitor, so resolving the SHA before taking this mutex could let
1653
+ # the heartbeat invalidate a producer's pending SCRIPT LOAD.
1654
+ @mutating_script_mutex.synchronize do
1655
+ script_sha = resolve_script(script_name)
1656
+ redis.without_reconnect do
1657
+ redis.evalsha(script_sha, keys: keys, argv: argv)
1658
+ end
1659
+ end
1660
+ end
1661
+ rescue Redis::CommandError => error
1662
+ if error.message.start_with?("NOSCRIPT") && attempts.zero?
1663
+ attempts += 1
1664
+ clear_script_cache(script_name)
1665
+ retry
1666
+ end
1667
+ raise
1668
+ end
1669
+ end
1670
+
1671
+ sig { params(script_name: Symbol).void }
1672
+ def clear_script_cache(script_name)
1673
+ case script_name
1674
+ when :register_consumergroup then @register_consumergroup_script = nil
1675
+ when :read_results then @read_results_script = nil
1676
+ when :reset_results then @reset_results_script = nil
1677
+ when :commit_results then @commit_results_script = nil
1678
+ when :cleanup then @cleanup_script = nil
1679
+ when :abort then @abort_script = nil
1680
+ when :adjust_results then @adjust_results_script = nil
1681
+ when :publish_tests then @publish_tests_script = nil
1682
+ when :heartbeat then @heartbeat_script = nil
1683
+ when :mark_attempt_state then @mark_attempt_state_script = nil
1684
+ else raise ArgumentError, "Unknown Redis script: #{script_name}"
1685
+ end
1686
+ end
1687
+
318
1688
  sig { params(block: Integer).returns(T::Array[EnqueuedRunnable]) }
319
1689
  def claim_fresh_runnables(block:)
320
1690
  result = redis.xreadgroup(
@@ -325,7 +1695,9 @@ module Minitest
325
1695
  block: block,
326
1696
  count: configuration.test_batch_size,
327
1697
  )
328
- EnqueuedRunnable.from_redis_stream_claim(result.fetch(stream_key, []), configuration: configuration)
1698
+ claims = result.fetch(stream_key, [])
1699
+ @combined_results = nil if claims.any?
1700
+ EnqueuedRunnable.from_redis_stream_claim(claims, configuration: configuration)
329
1701
  end
330
1702
 
331
1703
  sig do
@@ -446,48 +1818,502 @@ module Minitest
446
1818
  enqueued_runnables
447
1819
  end
448
1820
 
1821
+ sig { params(tests: T::Array[Minitest::Runnable]).void }
1822
+ def publish_tests(tests)
1823
+ generation = T.must(@attempt_generation)
1824
+ batches = tests.each_slice(PRODUCTION_BATCH_SIZE)
1825
+ batches = [[]].each if tests.empty?
1826
+ batches.each_with_index do |batch, index|
1827
+ final_batch = tests.empty? || (index + 1) * PRODUCTION_BATCH_SIZE >= tests.length
1828
+ argv = T.let(
1829
+ [generation, configuration.key_ttl_seconds, final_batch ? 1 : 0, batch.length, group_name],
1830
+ T::Array[T.untyped],
1831
+ )
1832
+ batch.each do |test|
1833
+ argv << T.must(test.class.name)
1834
+ argv << test.name
1835
+ end
1836
+ execute_script(
1837
+ script_name: :publish_tests,
1838
+ keys: [
1839
+ key("attempt_generation"),
1840
+ stream_key,
1841
+ key("production_complete"),
1842
+ key("production_heartbeat"),
1843
+ key("acks"),
1844
+ key("size"),
1845
+ key("completed_at"),
1846
+ list_key(ResultType::Failed.serialize),
1847
+ list_key(ResultType::Error.serialize),
1848
+ key("retry_snapshot_digest"),
1849
+ key("retention_ttl"),
1850
+ ],
1851
+ argv: argv,
1852
+ )
1853
+ end
1854
+ end
1855
+
1856
+ sig { void }
1857
+ def mark_run_truncated
1858
+ generation = T.must(@attempt_generation)
1859
+ applied = execute_script(
1860
+ script_name: :mark_attempt_state,
1861
+ keys: [
1862
+ key("attempt_generation"),
1863
+ key("truncated"),
1864
+ key("production_complete"),
1865
+ key("acks"),
1866
+ key("size"),
1867
+ key("truncated_generation"),
1868
+ key("retention_ttl"),
1869
+ ],
1870
+ argv: [generation, configuration.key_ttl_seconds],
1871
+ )
1872
+ return if [1, 2].include?(applied) || attempt_superseded?
1873
+
1874
+ abort_with_diagnostic(<<~DIAGNOSTIC)
1875
+ ERROR: minitest-distributed could not persist max-failures truncation state.
1876
+ run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
1877
+ The attempt generation key may have expired, been evicted, or been deleted.
1878
+ DIAGNOSTIC
1879
+ end
1880
+
1881
+ sig { returns(Thread) }
1882
+ def start_production_heartbeat
1883
+ generation = T.must(@attempt_generation)
1884
+ interval = [configuration.stall_timeout_seconds / 2, MAX_PRODUCTION_HEARTBEAT_INTERVAL_SECONDS].min
1885
+ control = HeartbeatControl.new
1886
+ @production_heartbeat_control = control
1887
+ @production_heartbeat_error = nil
1888
+ Thread.new do
1889
+ Thread.current.report_on_exception = false
1890
+ loop do
1891
+ stop_requested = control.mutex.synchronize do
1892
+ control.condition.wait(control.mutex, interval) unless control.stop_requested
1893
+ control.stop_requested
1894
+ end
1895
+ break if stop_requested
1896
+
1897
+ begin
1898
+ updated = execute_script(
1899
+ script_name: :heartbeat,
1900
+ keys: [key("attempt_generation"), key("production_heartbeat"), key("retention_ttl")],
1901
+ argv: [generation, configuration.key_ttl_seconds],
1902
+ )
1903
+ break unless updated == 1
1904
+ rescue Redis::BaseConnectionError
1905
+ # Redis clients reconnect on the next command. Keep this watchdog
1906
+ # alive so a transient connection failure does not silently stop
1907
+ # heartbeats during slow test discovery.
1908
+ next
1909
+ rescue StandardError => error
1910
+ @production_heartbeat_error = error
1911
+ break
1912
+ end
1913
+ end
1914
+ end
1915
+ end
1916
+
1917
+ sig { params(thread: Thread, propagate_error: T::Boolean).void }
1918
+ def stop_production_heartbeat(thread, propagate_error:)
1919
+ if (control = @production_heartbeat_control)
1920
+ control.mutex.synchronize do
1921
+ control.stop_requested = true
1922
+ control.condition.broadcast
1923
+ end
1924
+ end
1925
+ thread.join
1926
+ @production_heartbeat_control = nil
1927
+ heartbeat_error = @production_heartbeat_error
1928
+ raise heartbeat_error if heartbeat_error && propagate_error
1929
+ end
1930
+
1931
+ sig { params(results: ResultAggregate).returns(T::Boolean) }
1932
+ def run_complete?(results)
1933
+ results.equal?(@combined_results) && results.complete? && @combined_results_production_complete == true
1934
+ end
1935
+
1936
+ sig { returns(T.nilable(Integer)) }
1937
+ def current_production_heartbeat
1938
+ raw_heartbeat = read_control_string("production_heartbeat")
1939
+ return unless raw_heartbeat
1940
+
1941
+ heartbeat = parse_redis_integer(raw_heartbeat)
1942
+ raise CoordinatorStateError, "production_heartbeat=#{raw_heartbeat.inspect}" unless heartbeat
1943
+
1944
+ heartbeat
1945
+ end
1946
+
1947
+ # Read all the state needed to distinguish a legitimately slow pending
1948
+ # test from a drained queue whose completion counters can no longer agree.
1949
+ # This deliberately bypasses `@combined_results`, which is memoized.
1950
+ sig { returns(StallProbe) }
1951
+ def probe_stall
1952
+ counters, pending_summary, groups, stream_info, production_complete_type, heartbeat_type,
1953
+ generation_type = redis.pipelined do |pipeline|
1954
+ pipeline.mget(key("acks"), key("size"), key("production_complete"), key("production_heartbeat"))
1955
+ pipeline.xpending(stream_key, group_name)
1956
+ pipeline.xinfo("groups", stream_key)
1957
+ pipeline.xinfo("stream", stream_key)
1958
+ pipeline.call("TYPE", key("production_complete"))
1959
+ pipeline.call("TYPE", key("production_heartbeat"))
1960
+ pipeline.call("TYPE", key("attempt_generation"))
1961
+ end
1962
+
1963
+ raw_acks, raw_size, raw_production_complete, raw_production_heartbeat = T.unsafe(counters)
1964
+ group = T.unsafe(groups).find { |candidate| candidate.fetch("name") == group_name }
1965
+ raw_lag = group&.fetch("lag", nil)
1966
+ invalid_values = T.let([], T::Array[String])
1967
+ acks = parse_redis_integer(raw_acks)
1968
+ size = parse_redis_integer(raw_size)
1969
+ lag = parse_redis_integer(raw_lag)
1970
+ production_heartbeat = parse_redis_integer(raw_production_heartbeat)
1971
+ unless ["none", "string"].include?(production_complete_type)
1972
+ invalid_values << "production_complete_type=#{production_complete_type}"
1973
+ end
1974
+ unless ["none", "string"].include?(heartbeat_type)
1975
+ invalid_values << "production_heartbeat_type=#{heartbeat_type}"
1976
+ end
1977
+ invalid_values << "attempt_generation_type=#{generation_type}" unless generation_type == "string"
1978
+ if !raw_production_complete.nil? && raw_production_complete != "1"
1979
+ invalid_values << "production_complete=#{raw_production_complete.inspect}"
1980
+ end
1981
+ invalid_values << "acks=#{raw_acks.inspect}" if !raw_acks.nil? && acks.nil?
1982
+ invalid_values << "size=#{raw_size.inspect}" if !raw_size.nil? && size.nil?
1983
+ invalid_values << "lag=#{raw_lag.inspect}" if !raw_lag.nil? && lag.nil?
1984
+ if !raw_production_heartbeat.nil? && production_heartbeat.nil?
1985
+ invalid_values << "production_heartbeat=#{raw_production_heartbeat.inspect}"
1986
+ end
1987
+
1988
+ # Redis added the explicit group lag field in version 7. On older
1989
+ # versions, equal delivery and stream IDs still prove that there are no
1990
+ # undelivered entries because this coordinator never trims the stream.
1991
+ if lag.nil? && group && group.fetch("last-delivered-id") == T.unsafe(stream_info).fetch("last-generated-id")
1992
+ lag = 0
1993
+ end
1994
+
1995
+ raw_pending_count = T.unsafe(pending_summary).fetch("size")
1996
+ pending_count = parse_redis_integer(raw_pending_count)
1997
+ invalid_values << "pending=#{raw_pending_count.inspect}" if pending_count.nil?
1998
+
1999
+ StallProbe.new(
2000
+ acks: acks,
2001
+ size: size,
2002
+ pending_count: pending_count || 0,
2003
+ lag: lag,
2004
+ production_complete: raw_production_complete == "1",
2005
+ production_heartbeat: production_heartbeat,
2006
+ invalid_values: invalid_values,
2007
+ )
2008
+ end
2009
+
2010
+ sig { params(probe: StallProbe).void }
2011
+ def abort_stalled_run(probe)
2012
+ abort_with_diagnostic(format_stall_diagnostic(probe))
2013
+ end
2014
+
2015
+ sig { params(error: Redis::CommandError).void }
2016
+ def handle_coordinator_state_error(error)
2017
+ if attempt_truncated?
2018
+ @attempt_truncated = true
2019
+ @aborted = true
2020
+ emit_message(<<~DIAGNOSTIC)
2021
+ ERROR: minitest-distributed stopped this worker after another worker truncated the run.
2022
+ run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
2023
+ redis_error=#{error.message.inspect}
2024
+ The run was intentionally cut short at max_failures before this local batch could be committed.
2025
+ DIAGNOSTIC
2026
+ return
2027
+ end
2028
+ if attempt_superseded?
2029
+ @superseded = true
2030
+ return
2031
+ end
2032
+
2033
+ @combined_results = nil
2034
+ begin
2035
+ results = combined_results
2036
+ rescue CoordinatorStateError => parse_error
2037
+ abort_with_diagnostic(<<~DIAGNOSTIC)
2038
+ ERROR: minitest-distributed could not parse Redis coordinator statistics.
2039
+ run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
2040
+ redis_error=#{error.message.inspect} parse_error=#{parse_error.message.inspect}
2041
+ DIAGNOSTIC
2042
+ return
2043
+ rescue Redis::CommandError => state_error
2044
+ abort_with_diagnostic(<<~DIAGNOSTIC)
2045
+ ERROR: minitest-distributed lost required Redis coordinator state and aborted the run.
2046
+ run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
2047
+ redis_error=#{error.message.inspect} state_error=#{state_error.message.inspect}
2048
+ The retained coordinator statistics are missing, invalid, or inconsistent.
2049
+ The run is incomplete, so this was not normal cleanup.
2050
+ DIAGNOSTIC
2051
+ return
2052
+ end
2053
+ mandatory_state_missing = error.message.start_with?("COORDINATORSTATE", "WRONGTYPE")
2054
+ return if !mandatory_state_missing && run_complete?(results)
2055
+
2056
+ state = "run_id=#{configuration.run_id} worker_id=#{configuration.worker_id} " \
2057
+ "acks=#{results.acks} size=#{results.size} redis_error=#{error.message.inspect}"
2058
+ diagnostic = <<~DIAGNOSTIC
2059
+ ERROR: minitest-distributed lost required Redis coordinator state and aborted the run.
2060
+ #{state}
2061
+ The run is incomplete, so this was not normal cleanup. A run key may have expired, been evicted, or been deleted.
2062
+ DIAGNOSTIC
2063
+ abort_with_diagnostic(diagnostic)
2064
+ rescue CoordinatorStateError => parse_error
2065
+ abort_with_diagnostic(<<~DIAGNOSTIC)
2066
+ ERROR: minitest-distributed could not parse Redis coordinator state.
2067
+ run_id=#{configuration.run_id} worker_id=#{configuration.worker_id}
2068
+ redis_error=#{error.message.inspect} parse_error=#{parse_error.message.inspect}
2069
+ DIAGNOSTIC
2070
+ end
2071
+
2072
+ sig { returns(T::Boolean) }
2073
+ def attempt_superseded?
2074
+ attempt_generation = @attempt_generation
2075
+ return false unless attempt_generation
2076
+
2077
+ generation_key = key("attempt_generation")
2078
+ return false unless redis.type(generation_key) == "string"
2079
+
2080
+ current_generation = redis.get(generation_key)
2081
+ return false if current_generation.nil?
2082
+
2083
+ current_generation != attempt_generation
2084
+ end
2085
+
2086
+ sig { params(name: String).returns(T.nilable(String)) }
2087
+ def read_control_string(name)
2088
+ control_key = key(name)
2089
+ key_type = redis.type(control_key)
2090
+ return if key_type == "none"
2091
+ raise CoordinatorStateError, "#{name} has Redis type #{key_type}" unless key_type == "string"
2092
+
2093
+ redis.get(control_key)
2094
+ end
2095
+
2096
+ sig { returns(T::Boolean) }
2097
+ def commit_observed_truncation?
2098
+ @attempt_truncated
2099
+ end
2100
+
2101
+ sig { returns(T::Boolean) }
2102
+ def attempt_truncated?
2103
+ attempt_generation = @attempt_generation
2104
+ return false unless attempt_generation
2105
+
2106
+ truncated_key = key("truncated")
2107
+ truncated_generation_key = key("truncated_generation")
2108
+ return false unless redis.type(truncated_key) == "string"
2109
+ return false unless redis.type(truncated_generation_key) == "string"
2110
+
2111
+ redis.get(truncated_key) == "1" && redis.get(truncated_generation_key) == attempt_generation
2112
+ end
2113
+
2114
+ sig { params(diagnostic: String).void }
2115
+ def abort_locally_with_diagnostic(diagnostic)
2116
+ @aborted = true
2117
+ @stall_diagnostic = diagnostic
2118
+ emit_message(diagnostic)
2119
+ end
2120
+
2121
+ sig { params(diagnostic: String).void }
2122
+ def abort_with_diagnostic(diagnostic)
2123
+ generation = T.must(@attempt_generation)
2124
+ applied = execute_script(
2125
+ script_name: :abort,
2126
+ keys: [stream_key, key("attempt_generation"), key("stalled"), key("retention_ttl")],
2127
+ argv: [generation, group_name, configuration.key_ttl_seconds],
2128
+ )
2129
+ return if applied != 1 && attempt_superseded?
2130
+
2131
+ # If ownership evidence itself was evicted, do not mutate shared state,
2132
+ # but still fail this worker with the diagnostic rather than silently
2133
+ # continuing or reporting success.
2134
+ abort_locally_with_diagnostic(diagnostic)
2135
+ end
2136
+
2137
+ sig { params(probe: StallProbe).returns(String) }
2138
+ def format_stall_diagnostic(probe)
2139
+ <<~DIAGNOSTIC
2140
+ ERROR: minitest-distributed detected inconsistent Redis coordinator state and aborted the run.
2141
+ #{format_probe_state(probe)}
2142
+ The consumer group has no pending or undelivered tests, but acks does not equal size.
2143
+ One or more coordinator keys may have been evicted or deleted; the run cannot make further progress.
2144
+ DIAGNOSTIC
2145
+ end
2146
+
2147
+ sig { params(probe: StallProbe).returns(String) }
2148
+ def format_pending_stall_warning(probe)
2149
+ timeout = format("%g", configuration.stall_timeout_seconds)
2150
+ reclaim_after = format("%g", configuration.test_timeout_seconds * configuration.test_batch_size)
2151
+ <<~WARNING
2152
+ WARNING: minitest-distributed has made no progress for #{timeout}s, but Redis still has pending tests.
2153
+ #{format_probe_state(probe)}
2154
+ The worker will keep waiting; pending tests become reclaimable after approximately #{reclaim_after}s.
2155
+ WARNING
2156
+ end
2157
+
2158
+ sig { params(probe: StallProbe).returns(String) }
2159
+ def format_probe_state(probe)
2160
+ "run_id=#{configuration.run_id} worker_id=#{configuration.worker_id} " \
2161
+ "acks=#{format_probe_value(probe.acks)} size=#{format_probe_value(probe.size)} " \
2162
+ "pending=#{probe.pending_count} lag=#{format_probe_value(probe.lag)} " \
2163
+ "production_complete=#{probe.production_complete} " \
2164
+ "production_heartbeat=#{format_probe_value(probe.production_heartbeat)}"
2165
+ end
2166
+
2167
+ sig { params(value: T.untyped).returns(T.nilable(Integer)) }
2168
+ def parse_redis_integer(value)
2169
+ integer = case value
2170
+ when Integer then value
2171
+ when String then value.match?(/\A-?\d+\z/) ? value.to_i : nil
2172
+ end
2173
+ return unless integer
2174
+
2175
+ integer if integer.between?(0, MAX_SAFE_STATISTIC)
2176
+ end
2177
+
2178
+ sig { params(value: T.nilable(Integer)).returns(String) }
2179
+ def format_probe_value(value)
2180
+ value.nil? ? "missing" : value.to_s
2181
+ end
2182
+
2183
+ sig { params(message: String).void }
2184
+ def emit_message(message)
2185
+ (@output || $stderr).puts(message)
2186
+ end
2187
+
2188
+ sig { returns(Float) }
2189
+ def monotonic_time
2190
+ Process.clock_gettime(Process::CLOCK_MONOTONIC).to_f
2191
+ end
2192
+
449
2193
  sig { void }
450
2194
  def cleanup
451
- redis.xgroup(:destroy, stream_key, group_name)
452
- redis.del(stream_key)
453
- rescue Redis::CommandError
454
- # Apparently another consumer already removed the consumer group,
455
- # so we can assume that all the Redis cleanup was completed.
456
- end
457
-
458
- sig { params(results: ResultAggregate).void }
459
- def adjust_combined_results(results)
460
- updated = redis.multi do |pipeline|
461
- pipeline.incrby(key("runs"), results.runs)
462
- pipeline.incrby(key("assertions"), results.assertions)
463
- pipeline.incrby(key("passes"), results.passes)
464
- pipeline.incrby(key("failures"), results.failures)
465
- pipeline.incrby(key("errors"), results.errors)
466
- pipeline.incrby(key("skips"), results.skips)
467
- pipeline.incrby(key("requeues"), results.requeues)
468
- pipeline.incrby(key("discards"), results.discards)
469
- pipeline.incrby(key("acks"), results.acks)
470
- pipeline.incrby(key("size"), results.size)
2195
+ generation = T.must(@attempt_generation)
2196
+ execute_script(
2197
+ script_name: :cleanup,
2198
+ keys: [stream_key, key("attempt_generation"), key("retention_ttl")],
2199
+ argv: [generation, group_name, configuration.key_ttl_seconds],
2200
+ )
2201
+ end
2202
+
2203
+ # The keys the coordinator writes for a run, other than the stream, are plain
2204
+ # counters, lists and sets that intentionally survive `cleanup`: retry mode and
2205
+ # the summary reporters read them after the stream is gone. Nothing ever removes
2206
+ # them, so a coordinator accumulates roughly a dozen keys per run, forever.
2207
+ #
2208
+ # That matters most where the coordinator is a shared Redis running an
2209
+ # `allkeys-lru` eviction policy at maxmemory, because LRU is then free to evict
2210
+ # the keys of a *running* build. Losing one is silent and unrecoverable: a run
2211
+ # only finishes when `acks == size`, so every worker keeps polling a drained
2212
+ # queue, printing nothing, until CI kills it.
2213
+ #
2214
+ # An expiry does not stop an eviction, and is not claimed to. It bounds what a
2215
+ # finished run leaves behind, which is the part this gem owns.
2216
+ STATS_KEY_NAMES = T.let(
2217
+ ["runs", "assertions", "passes", "failures", "errors", "skips", "requeues", "discards", "acks", "size"].freeze,
2218
+ T::Array[String],
2219
+ )
2220
+ private_constant :STATS_KEY_NAMES
2221
+
2222
+ LIST_KEY_RESULT_TYPES = T.let(
2223
+ [ResultType::Skipped, ResultType::Failed, ResultType::Error].freeze,
2224
+ T::Array[ResultType],
2225
+ )
2226
+ private_constant :LIST_KEY_RESULT_TYPES
2227
+
2228
+ sig { params(identifiers: T::Array[String]).returns(T.nilable(T::Array[Minitest::Runnable])) }
2229
+ def materialize_retry_tests(identifiers)
2230
+ identifiers.map do |identifier|
2231
+ runnable = DefinedRunnable.from_identifier(identifier)
2232
+ raise NameError, "retained test method no longer exists: #{identifier}" unless runnable.respond_to?(runnable.name)
2233
+
2234
+ runnable
471
2235
  end
2236
+ rescue StandardError
2237
+ nil
2238
+ end
472
2239
 
473
- @combined_results = ResultAggregate.new(
474
- max_failures: configuration.max_failures,
475
- runs: updated[0],
476
- assertions: updated[1],
477
- passes: updated[2],
478
- failures: updated[3],
479
- errors: updated[4],
480
- skips: updated[5],
481
- requeues: updated[6],
482
- discards: updated[7],
483
- acks: updated[8],
484
- size: updated[9],
2240
+ sig { params(size: Integer).void }
2241
+ def reset_combined_results_for_full_rerun(size:)
2242
+ generation = T.must(@attempt_generation)
2243
+ keys = [
2244
+ key("attempt_generation"),
2245
+ stream_key,
2246
+ key("retry_set"),
2247
+ key("production_complete"),
2248
+ key("production_heartbeat"),
2249
+ key("stalled"),
2250
+ key("truncated"),
2251
+ ]
2252
+ keys.concat(STATS_KEY_NAMES.map { |name| key(name) })
2253
+ keys.concat(LIST_KEY_RESULT_TYPES.map { |result_type| list_key(result_type.serialize) })
2254
+ keys.push(
2255
+ key("completed_at"),
2256
+ key("retry_snapshot_digest"),
2257
+ key("truncated_generation"),
2258
+ key("retention_ttl"),
2259
+ )
2260
+ updated = execute_script(
2261
+ script_name: :reset_results,
2262
+ keys: keys,
2263
+ argv: [generation, configuration.key_ttl_seconds, size],
2264
+ )
2265
+ update_combined_results(T.cast(updated, T::Array[Integer]), production_complete: false)
2266
+ end
2267
+
2268
+ sig do
2269
+ params(
2270
+ results: ResultAggregate,
2271
+ clear_retry_lists: T::Boolean,
2272
+ allow_missing_stats: T::Boolean,
2273
+ ).void
2274
+ end
2275
+ def adjust_combined_results(results, clear_retry_lists: false, allow_missing_stats: false)
2276
+ generation = T.must(@attempt_generation)
2277
+ keys = [
2278
+ key("attempt_generation"),
2279
+ stream_key,
2280
+ key("retry_set"),
2281
+ key("production_complete"),
2282
+ key("production_heartbeat"),
2283
+ key("stalled"),
2284
+ key("truncated"),
2285
+ ]
2286
+ keys.concat(STATS_KEY_NAMES.map { |name| key(name) })
2287
+ keys.concat(LIST_KEY_RESULT_TYPES.map { |result_type| list_key(result_type.serialize) })
2288
+ keys.push(
2289
+ key("completed_at"),
2290
+ key("retry_snapshot_digest"),
2291
+ key("truncated_generation"),
2292
+ key("retention_ttl"),
485
2293
  )
2294
+ argv = [
2295
+ generation,
2296
+ configuration.key_ttl_seconds,
2297
+ clear_retry_lists ? 1 : 0,
2298
+ allow_missing_stats ? 1 : 0,
2299
+ results.runs,
2300
+ results.assertions,
2301
+ results.passes,
2302
+ results.failures,
2303
+ results.errors,
2304
+ results.skips,
2305
+ results.requeues,
2306
+ results.discards,
2307
+ results.acks,
2308
+ results.size,
2309
+ ]
2310
+ updated = execute_script(script_name: :adjust_results, keys: keys, argv: argv)
2311
+ update_combined_results(T.cast(updated, T::Array[Integer]), production_complete: false)
486
2312
  end
487
2313
 
488
2314
  sig { params(name: String).returns(String) }
489
2315
  def key(name)
490
- "minitest/#{configuration.run_id}/#{name}"
2316
+ "#{REDIS_PROTOCOL_NAMESPACE}/#{configuration.run_id}/#{name}"
491
2317
  end
492
2318
 
493
2319
  sig { params(name: String).returns(String) }
@@ -495,6 +2321,119 @@ module Minitest
495
2321
  key("#{name}_list")
496
2322
  end
497
2323
 
2324
+ sig do
2325
+ params(
2326
+ results: T::Array[[EnqueuedRunnable, Minitest::Result]],
2327
+ ).returns(T::Array[EnqueuedRunnable::Result])
2328
+ end
2329
+ def commit_results(results)
2330
+ arguments = T.let(
2331
+ [group_name, configuration.key_ttl_seconds, results.size, T.must(@attempt_generation)],
2332
+ T::Array[T.untyped],
2333
+ )
2334
+ results.each do |enqueued_runnable, result|
2335
+ arguments.push(
2336
+ enqueued_runnable.entry_id,
2337
+ ResultType.of(result).serialize,
2338
+ enqueued_runnable.attempt_id,
2339
+ enqueued_runnable.identifier,
2340
+ result.assertions,
2341
+ )
2342
+ end
2343
+
2344
+ keys = [stream_key, key("retry_set")]
2345
+ keys.concat(STATS_KEY_NAMES.map { |name| key(name) })
2346
+ keys.concat(LIST_KEY_RESULT_TYPES.map { |result_type| list_key(result_type.serialize) })
2347
+ keys.push(
2348
+ key("production_complete"),
2349
+ key("stalled"),
2350
+ key("production_heartbeat"),
2351
+ key("attempt_generation"),
2352
+ key("truncated"),
2353
+ key("completed_at"),
2354
+ key("retry_snapshot_digest"),
2355
+ key("truncated_generation"),
2356
+ key("retention_ttl"),
2357
+ )
2358
+
2359
+ response = T.unsafe(execute_script(script_name: :commit_results, keys: keys, argv: arguments))
2360
+ commit_statuses = T.cast(response.take(results.size), T::Array[Integer])
2361
+ aggregate_response = T.cast(response.drop(results.size), T::Array[Integer])
2362
+ attempt_truncated = aggregate_response.pop == 1
2363
+ production_complete = aggregate_response.pop == 1
2364
+ if attempt_truncated
2365
+ @attempt_truncated = true
2366
+ @aborted = true
2367
+ end
2368
+ update_combined_results(aggregate_response, production_complete: production_complete)
2369
+ build_runnable_results(results, commit_statuses, force_discard: attempt_truncated)
2370
+ rescue Redis::CommandError => error
2371
+ if error.message.start_with?("STALEATTEMPT")
2372
+ @superseded = true
2373
+ # The Lua script already proved supersession atomically. Do not
2374
+ # re-read generation or truncation keys here: they may be evicted
2375
+ # before Ruby handles the response, but the executed batch must
2376
+ # still be recorded locally as discarded.
2377
+ return build_runnable_results(results, Array.new(results.size, 0), force_discard: true)
2378
+ end
2379
+
2380
+ cleanup_race_error = error.message.start_with?("NOGROUP", "COORDINATORCONFIG", "COORDINATORSTREAM") ||
2381
+ error.message.include?("no such key")
2382
+ raise unless cleanup_race_error
2383
+
2384
+ # Another worker may have completed and cleaned up while this batch was
2385
+ # running. Preserve the reporter contract by presenting these local
2386
+ # results as uncommitted/discarded, but only when fresh counters prove
2387
+ # the shared run is already terminal. Incomplete state is handled by
2388
+ # consume's fail-closed coordinator-state path.
2389
+ @combined_results = nil
2390
+ terminal_results = combined_results
2391
+ raise unless run_complete?(terminal_results)
2392
+
2393
+ build_runnable_results(results, Array.new(results.size, 0), force_discard: true)
2394
+ end
2395
+
2396
+ sig do
2397
+ params(
2398
+ results: T::Array[[EnqueuedRunnable, Minitest::Result]],
2399
+ commit_statuses: T::Array[Integer],
2400
+ force_discard: T::Boolean,
2401
+ ).returns(T::Array[EnqueuedRunnable::Result])
2402
+ end
2403
+ def build_runnable_results(results, commit_statuses, force_discard: false)
2404
+ results.each_with_index.map do |(enqueued_runnable, result), index|
2405
+ commit = if commit_statuses.fetch(index) == 1
2406
+ EnqueuedRunnable::Result::Commit.success
2407
+ else
2408
+ EnqueuedRunnable::Result::Commit.failure
2409
+ end
2410
+ EnqueuedRunnable::Result.new(
2411
+ enqueued_runnable: enqueued_runnable,
2412
+ initial_result: result,
2413
+ commit: commit,
2414
+ force_discard: force_discard,
2415
+ )
2416
+ end
2417
+ end
2418
+
2419
+ sig { params(updated: T::Array[Integer], production_complete: T::Boolean).void }
2420
+ def update_combined_results(updated, production_complete:)
2421
+ @combined_results_production_complete = production_complete
2422
+ @combined_results = ResultAggregate.new(
2423
+ max_failures: configuration.max_failures,
2424
+ runs: updated.fetch(0),
2425
+ assertions: updated.fetch(1),
2426
+ passes: updated.fetch(2),
2427
+ failures: updated.fetch(3),
2428
+ errors: updated.fetch(4),
2429
+ skips: updated.fetch(5),
2430
+ requeues: updated.fetch(6),
2431
+ discards: updated.fetch(7),
2432
+ acks: updated.fetch(8),
2433
+ size: updated.fetch(9),
2434
+ )
2435
+ end
2436
+
498
2437
  sig { params(batch: T::Array[EnqueuedRunnable], reporter: AbstractReporter).void }
499
2438
  def process_batch(batch, reporter)
500
2439
  return 0 if batch.empty?
@@ -507,42 +2446,17 @@ module Minitest
507
2446
  [enqueued_runnable, enqueued_runnable.run]
508
2447
  end
509
2448
 
510
- # Try to commit all the results of this batch to Redis
511
- runnable_results = []
512
- redis.multi do |pipeline|
513
- results.each do |enqueued_runnable, initial_result|
514
- runnable_results << enqueued_runnable.commit_result(initial_result) do |result_to_commit|
515
- if ResultType.of(result_to_commit) == ResultType::Requeued
516
- sadd_future = pipeline.sadd(key("retry_set"), [enqueued_runnable.attempt_id])
517
- EnqueuedRunnable::Result::Commit.new { sadd_future.value > 0 }
518
- else
519
- xack_future = pipeline.xack(stream_key, group_name, enqueued_runnable.entry_id)
520
- EnqueuedRunnable::Result::Commit.new { xack_future.value == 1 }
521
- end
522
- end
523
- end
524
- end
525
-
526
- batch_result_aggregate = ResultAggregate.new
2449
+ # XACK/SADD, result-list writes, statistics and expiries are committed
2450
+ # atomically so a diagnostic probe cannot observe a valid half-commit.
2451
+ runnable_results = commit_results(results)
527
2452
  runnable_results.each do |runnable_result|
528
2453
  # Complete the reporter contract by calling `record` with the result.
529
2454
  reporter.record(runnable_result.committed_result)
530
2455
 
531
- # Update statistics.
532
- batch_result_aggregate.update_with_result(runnable_result)
2456
+ # The combined statistics were updated by `commit_results`; only this
2457
+ # worker's local statistics remain to be recorded here.
533
2458
  local_results.update_with_result(runnable_result)
534
-
535
- case (result_type = ResultType.of(runnable_result.committed_result))
536
- when ResultType::Skipped, ResultType::Failed, ResultType::Error
537
- redis.lpush(list_key(result_type.serialize), runnable_result.enqueued_runnable.identifier)
538
- when ResultType::Passed, ResultType::Requeued, ResultType::Discarded
539
- # noop
540
- else
541
- T.absurd(result_type)
542
- end
543
2459
  end
544
-
545
- adjust_combined_results(batch_result_aggregate)
546
2460
  end
547
2461
 
548
2462
  sig { returns(T.nilable(Logger)) }
@@ -557,6 +2471,26 @@ module Minitest
557
2471
  [backoff << 1, MAX_BACKOFF].min
558
2472
  end
559
2473
 
2474
+ REDIS_PROTOCOL_NAMESPACE = "minitest/v3"
2475
+ private_constant :REDIS_PROTOCOL_NAMESPACE
2476
+
2477
+ BASE_GROUP_NAME = "minitest-distributed-v3"
2478
+ private_constant :BASE_GROUP_NAME
2479
+
2480
+ # Confirm a drained mismatch because the diagnostic probe itself uses
2481
+ # multiple pipelined Redis commands and is not an atomic snapshot.
2482
+ STALL_CONFIRMATION_SECONDS = 30.0
2483
+ private_constant :STALL_CONFIRMATION_SECONDS
2484
+
2485
+ MAX_PRODUCTION_HEARTBEAT_INTERVAL_SECONDS = 30.0
2486
+ private_constant :MAX_PRODUCTION_HEARTBEAT_INTERVAL_SECONDS
2487
+
2488
+ MAX_SAFE_STATISTIC = 9_007_199_254_740_991
2489
+ private_constant :MAX_SAFE_STATISTIC
2490
+
2491
+ PRODUCTION_BATCH_SIZE = 100
2492
+ private_constant :PRODUCTION_BATCH_SIZE
2493
+
560
2494
  INITIAL_BACKOFF = 10 # milliseconds
561
2495
  private_constant :INITIAL_BACKOFF
562
2496