minitest-distributed 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3755320df95c6d8e5ba5e32b04b44351043588d43c309d4d746cc36f96567132
4
- data.tar.gz: 785503f39eadcfab72b7fc3df6e8f19d3ad0fbaea793e68f9f233ed08c4f6452
3
+ metadata.gz: 1605e583ce4427da1909789380c1ac20ffe629a5f1ddf96452f93e45d50188f2
4
+ data.tar.gz: 052ba1210bee26b878052bb28207b800a67682f023e6fdcc1e03bebaea758376
5
5
  SHA512:
6
- metadata.gz: fd42edc7fd402f38c08f22ff17fa8430b65cae6d1c53b27afa55446ab5cb6e90f0c0d8a92c6fa7a7665d7aba4a81768a52d9a5c1007da2ebf6407414c2538510
7
- data.tar.gz: e3b5119c06eaec10a7f8befacec8b3fde636e17eb9c8a4b539ae4aedf95d00217a97911ccf067e14f08850e1dbc3a6d072723811b565ea06aff46be8fb2607ae
6
+ metadata.gz: fb550faaa06a888561a3557b10f35fc867ca341e6e4008a49eb5c5137aa79b3490e6a5b24444d67c85ffc79c2e683aff7a21f948e7867b1b78bbafeccdebe9b8
7
+ data.tar.gz: ad60584710485b804af453696912a2676d56905e69642ead265111689ea10212768925f5c48017fd3c6da7547a94e8450b181dd2f89b5db82dc480d79d843d10
data/dev.yml CHANGED
@@ -5,3 +5,4 @@ type: ruby
5
5
  up:
6
6
  - ruby
7
7
  - bundler
8
+ - redis
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "redis"
5
5
  require "set"
6
+ require "logger"
6
7
 
7
8
  module Minitest
8
9
  module Distributed
@@ -152,63 +153,65 @@ module Minitest
152
153
 
153
154
  return if consumer_group_exists
154
155
 
155
- tests = T.let([], T::Array[Minitest::Runnable])
156
- tests = if initial_attempt
157
- # If this is the first attempt for this run ID, we will schedule the full
158
- # test suite as returned by the test selector to run.
159
-
160
- tests_from_selector = test_selector.tests
161
- adjust_combined_results(ResultAggregate.new(size: tests_from_selector.size))
162
- tests_from_selector
163
-
164
- elsif configuration.retry_failures
165
- # Before starting a retry attempt, we first check if the previous attempt
166
- # was aborted before it was completed. If this is the case, we cannot use
167
- # retry mode, and should immediately fail the attempt.
168
- if combined_results.abort?
169
- # We mark this run as aborted, which causes this worker to not be successful.
170
- @aborted = true
171
-
172
- # We still publish an empty size run to Redis, so if there are any followers,
173
- # they will wind down normally. Only the leader will exit
174
- # with a non-zero exit status and fail the build; any follower will
175
- # exit with status 0.
176
- adjust_combined_results(ResultAggregate.new(size: 0))
177
- T.let([], T::Array[Minitest::Runnable])
178
- else
179
- previous_failures, previous_errors, _deleted = redis.multi do |pipeline|
180
- pipeline.lrange(list_key(ResultType::Failed.serialize), 0, -1)
181
- pipeline.lrange(list_key(ResultType::Error.serialize), 0, -1)
182
- pipeline.del(list_key(ResultType::Failed.serialize), list_key(ResultType::Error.serialize))
183
- end
156
+ 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
+
161
+ tests_from_selector = test_selector.tests
162
+ adjust_combined_results(ResultAggregate.new(size: tests_from_selector.size))
163
+ 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.
177
+ adjust_combined_results(ResultAggregate.new(size: 0))
178
+ []
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
184
185
 
185
- # We set the `size` key to the number of tests we are planning to schedule.
186
- # We also adjust the number of failures and errors back to 0.
187
- # We set the number of requeues to the number of tests that failed, so the
188
- # run statistics will reflect that we retried some failed test.
189
- #
190
- # However, normally requeues are not acked, as we expect the test to be acked
191
- # by another worker later. This makes the test loop think iot is already done.
192
- # To prevent this, we initialize the number of acks negatively, so it evens out
193
- # in the statistics.
194
- total_failures = previous_failures.length + previous_errors.length
195
- adjust_combined_results(ResultAggregate.new(
196
- size: total_failures,
197
- failures: -previous_failures.length,
198
- errors: -previous_errors.length,
199
- requeues: total_failures,
200
- ))
201
-
202
- # For subsequent attempts, we check the list of previous failures and
203
- # errors, and only schedule to re-run those tests. This allows for faster
204
- # retries of potentially flaky tests.
205
- test_identifiers_to_retry = T.let(previous_failures + previous_errors, T::Array[String])
206
- test_identifiers_to_retry.map { |identifier| DefinedRunnable.from_identifier(identifier) }
207
- end
208
- else
209
- adjust_combined_results(ResultAggregate.new(size: 0))
210
- T.let([], T::Array[Minitest::Runnable])
211
- end
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
+ end
209
+ else
210
+ adjust_combined_results(ResultAggregate.new(size: 0))
211
+ []
212
+ end,
213
+ T::Array[Minitest::Runnable],
214
+ )
212
215
 
213
216
  redis.pipelined do |pipeline|
214
217
  tests.each do |test|
@@ -242,10 +245,17 @@ module Minitest
242
245
  # To make sure we don't end up in a busy loop overwhelming Redis with commands
243
246
  # when there is no work to do, we increase the blocking time exponentially,
244
247
  # and reset it to the initial value if we processed any tests.
245
- if stale_runnables.empty? && fresh_runnables.empty?
246
- exponential_backoff <<= 1
248
+ #
249
+ # The backoff is capped at MAX_BACKOFF to bound how long a worker can sit
250
+ # inside a single XREADGROUP BLOCK call. Without a cap, after ~15 empty
251
+ # iterations the worker is blocked in Redis for 5+ minutes and cannot
252
+ # re-check `complete?` / `abort?` until the BLOCK returns, which manifests
253
+ # as a long post-100% teardown hang when pipelined XACKs race the progress
254
+ # reporter.
255
+ exponential_backoff = if stale_runnables.empty? && fresh_runnables.empty?
256
+ next_backoff(exponential_backoff)
247
257
  else
248
- exponential_backoff = INITIAL_BACKOFF
258
+ INITIAL_BACKOFF
249
259
  end
250
260
  end
251
261
 
@@ -268,7 +278,25 @@ module Minitest
268
278
 
269
279
  sig { returns(Redis) }
270
280
  def redis
271
- @redis ||= Redis.new(url: configuration.coordinator_uri.to_s)
281
+ @redis ||= Redis.new(
282
+ url: configuration.coordinator_uri.to_s,
283
+ middlewares: custom_middlewares,
284
+ custom: custom_config,
285
+ timeout: 2,
286
+ )
287
+ end
288
+
289
+ sig { returns(T.nilable(T::Array[Module])) }
290
+ def custom_middlewares
291
+ return unless ENV.key?("MINITEST_DISTRIBUTED_REDIS_LOG")
292
+
293
+ require_relative "redis_instrumentation_middleware"
294
+ [RedisInstrumentationMiddleware]
295
+ end
296
+
297
+ sig { returns(T.nilable(T::Hash[Symbol, File])) }
298
+ def custom_config
299
+ { log_file: logger }.compact
272
300
  end
273
301
 
274
302
  sig { returns(String) }
@@ -517,8 +545,27 @@ module Minitest
517
545
  adjust_combined_results(batch_result_aggregate)
518
546
  end
519
547
 
548
+ sig { returns(T.nilable(Logger)) }
549
+ def logger
550
+ return unless (log_path = ENV["MINITEST_DISTRIBUTED_REDIS_LOG"])
551
+
552
+ @logger ||= T.let(Logger.new(log_path), T.nilable(Logger))
553
+ end
554
+
555
+ sig { params(backoff: Integer).returns(Integer) }
556
+ def next_backoff(backoff)
557
+ [backoff << 1, MAX_BACKOFF].min
558
+ end
559
+
520
560
  INITIAL_BACKOFF = 10 # milliseconds
521
561
  private_constant :INITIAL_BACKOFF
562
+
563
+ # Cap on the XREADGROUP BLOCK timeout used by `consume`. Reached after roughly
564
+ # 9 consecutive empty iterations (10 ms * 2^9 = 5120 ms). Bounds the worst-case
565
+ # time a worker can be unresponsive to `complete?` / `abort?` after the queue
566
+ # is drained.
567
+ MAX_BACKOFF = 5_000 # milliseconds
568
+ private_constant :MAX_BACKOFF
522
569
  end
523
570
  end
524
571
  end
@@ -0,0 +1,39 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "redis"
5
+
6
+ module Minitest
7
+ module Distributed
8
+ module Coordinators
9
+ # Redis middleware that logs all Redis commands to a file for debugging.
10
+ module RedisInstrumentationMiddleware
11
+ extend T::Sig
12
+
13
+ sig { params(command: T::Array[T.untyped], redis_config: T.untyped).returns(T.untyped) }
14
+ def call(command, redis_config)
15
+ log_file = redis_config.custom[:log_file]
16
+ log_file.info("EXEC: #{command.inspect}")
17
+ result = super
18
+ log_file.info("RESULT: #{result.inspect}")
19
+ result
20
+ rescue => e
21
+ log_file.info("ERROR: #{e.class}")
22
+ Kernel.raise
23
+ end
24
+
25
+ sig { params(commands: T::Array[T.untyped], redis_config: T.untyped).returns(T.untyped) }
26
+ def call_pipelined(commands, redis_config)
27
+ log_file = redis_config.custom[:log_file]
28
+ log_file.info("EXEC PIPELINED: #{commands.inspect}")
29
+ result = super
30
+ log_file.info("RESULT PIPELINED: #{result.inspect}")
31
+ result
32
+ rescue => e
33
+ log_file.info("ERROR PIPELINED: #{e.class}")
34
+ Kernel.raise
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -3,6 +3,6 @@
3
3
 
4
4
  module Minitest
5
5
  module Distributed
6
- VERSION = "0.2.11"
6
+ VERSION = "0.2.13"
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: minitest-distributed
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.11
4
+ version: 0.2.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Willem van Bergen
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-09-16 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: minitest
@@ -116,6 +115,7 @@ files:
116
115
  - lib/minitest/distributed/coordinators/coordinator_interface.rb
117
116
  - lib/minitest/distributed/coordinators/memory_coordinator.rb
118
117
  - lib/minitest/distributed/coordinators/redis_coordinator.rb
118
+ - lib/minitest/distributed/coordinators/redis_instrumentation_middleware.rb
119
119
  - lib/minitest/distributed/enqueued_runnable.rb
120
120
  - lib/minitest/distributed/filters/exclude_file_filter.rb
121
121
  - lib/minitest/distributed/filters/exclude_filter.rb
@@ -169,7 +169,6 @@ metadata:
169
169
  allowed_push_host: https://rubygems.org
170
170
  homepage_uri: https://github.com/Shopify/minitest-distributed
171
171
  source_code_uri: https://github.com/Shopify/minitest-distributed
172
- post_install_message:
173
172
  rdoc_options: []
174
173
  require_paths:
175
174
  - lib
@@ -184,8 +183,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
184
183
  - !ruby/object:Gem::Version
185
184
  version: '0'
186
185
  requirements: []
187
- rubygems_version: 3.5.18
188
- signing_key:
186
+ rubygems_version: 4.0.12
189
187
  specification_version: 4
190
188
  summary: Distributed test executor plugin for Minitest
191
189
  test_files: []