ci-queue 0.80.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8f6b51411e322411c49898ac1a082148ca8b584769d8d3e52ddfd87b11cbb96
4
- data.tar.gz: ee4e4beba8f6d2f97400631f7ac7b47ed8561c2af223e369ad9fcdbe88f4c6e3
3
+ metadata.gz: 3db30c3671ffa851f03a771eaf73a1210b51213b4039cdb17bfa29489a5b21ec
4
+ data.tar.gz: 5ae004105d90eb2d1383439f71ce355643c67b5e1e35a75782fb6b94815a62b1
5
5
  SHA512:
6
- metadata.gz: d8f03c40ee904cc069300219ffd71262237de5d291eae1ab682289faae8108f90ec127cbd327b5ea1c0afe9a674e493670e3097922f8848bc3757ebbcb8c38f4
7
- data.tar.gz: 8c9904ae453b65bd1be76f5cf3c49098f33a2cc1270699ec7569477465ff3c6369931da7e9a2eae789fc1949b07341148d2e8e73276d0362568bd65801fc6723
6
+ metadata.gz: 4a1c6ba221d268f37b32d804fde95827159e79dceaf51a156af5abf774e57a04c4dc98d781f3f7c9bcd065006b1b11e70ee2237e3e6109eb9f32c3bef9da2f3b
7
+ data.tar.gz: a62858950ae3c22ad1f7ae0dba2f85f5daa6de2fa17730efe2faf91b32b1e3be42ee750f65febc48cc53757e417a9f449d177cecf4dcaffb7b03736825a1d7d4
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ci-queue (0.80.0)
4
+ ci-queue (0.82.0)
5
5
  logger
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -71,6 +71,28 @@ rspec-queue --queue redis://example.com --timeout 600 --report
71
71
 
72
72
  Because of how `ci-queue` executes the examples, `before(:all)` and `after(:all)` hooks are not supported. `rspec-queue` will explicitly reject them.
73
73
 
74
+ ## Releasing a New Version
75
+
76
+ After merging changes to `main`, follow these steps to release and propagate the update:
77
+
78
+ 1. **Bump the version** in `ruby/lib/ci/queue/version.rb`:
79
+
80
+ ```ruby
81
+ VERSION = '0.XX.0'
82
+ ```
83
+
84
+ 2. **Update `Gemfile.lock`** by running `bundle install` in the `ruby/` directory (or manually updating the version string in `Gemfile.lock` if native dependencies prevent `bundle install`).
85
+
86
+ 3. **Commit and merge** the version bump to `main`. ShipIt will automatically publish the gem to RubyGems.
87
+
88
+ 4. **Update dependent apps/zones**: Any application that depends on `ci-queue` (e.g. via its `Gemfile`) needs to pick up the new version by running:
89
+
90
+ ```bash
91
+ bundle update ci-queue
92
+ ```
93
+
94
+ This updates the app's `Gemfile.lock` to reference the new `ci-queue` version. Commit the updated `Gemfile.lock` and deploy.
95
+
74
96
  ## Custom Redis Expiry
75
97
 
76
98
  `ci-queue` expects the Redis server to have an [eviction policy](https://redis.io/docs/manual/eviction/#eviction-policies) of `allkeys-lru`.
@@ -18,18 +18,35 @@ module CI
18
18
  @queue.exhausted?
19
19
  end
20
20
 
21
- def record_error(id, payload, stats: nil)
21
+ def record_error(id, payload, stat_delta: nil)
22
22
  error_reports[id] = payload
23
- record_stats(stats)
23
+ true
24
24
  end
25
25
 
26
- def record_success(id, stats: nil, skip_flaky_record: false, acknowledge: true)
26
+ def record_success(id, skip_flaky_record: false, acknowledge: true)
27
27
  error_reports.delete(id)
28
- record_stats(stats)
28
+ true
29
+ end
30
+
31
+ def record_requeue(id)
32
+ true
33
+ end
34
+
35
+ def record_stats(builds_stats)
36
+ return unless builds_stats
37
+ stats.merge!(builds_stats)
38
+ end
39
+
40
+ def record_stats_delta(delta, pipeline: nil)
41
+ return if delta.nil? || delta.empty?
42
+ delta.each do |stat_name, value|
43
+ next unless value.is_a?(Numeric) || value.to_s.match?(/\A-?\d+\.?\d*\z/)
44
+ stats[stat_name] = (stats[stat_name] || 0).to_f + value.to_f
45
+ end
29
46
  end
30
47
 
31
48
  def fetch_stats(stat_names)
32
- stat_names.zip(stats.values_at(*stat_names).map(&:to_f))
49
+ stat_names.zip(stats.values_at(*stat_names).map(&:to_f)).to_h
33
50
  end
34
51
 
35
52
  def reset_stats(stat_names)
@@ -47,11 +64,6 @@ module CI
47
64
  private
48
65
 
49
66
  attr_reader :stats
50
-
51
- def record_stats(builds_stats)
52
- return unless builds_stats
53
- stats.merge!(builds_stats)
54
- end
55
67
  end
56
68
  end
57
69
  end
@@ -214,7 +214,7 @@ module CI
214
214
  end
215
215
 
216
216
  def key(*args)
217
- KeyShortener.key(config.build_id, *args)
217
+ ['build', build_id, *args].join(':')
218
218
  end
219
219
 
220
220
  def build_id
@@ -56,30 +56,71 @@ module CI
56
56
  redis.rpush(key('warnings'), Marshal.dump([type, attributes]))
57
57
  end
58
58
 
59
- def record_error(id, payload, stats: nil)
60
- acknowledged, _ = redis.pipelined do |pipeline|
61
- @queue.acknowledge(id, error: payload, pipeline: pipeline)
62
- record_stats(stats, pipeline: pipeline)
59
+ def record_error(id, payload, stat_delta: nil)
60
+ # Run acknowledge first so we know whether we're the first to ack
61
+ acknowledged = @queue.acknowledge(id, error: payload)
62
+
63
+ if acknowledged
64
+ # We were the first to ack; another worker already ack'd would get falsy from SADD
65
+ @queue.increment_test_failed
66
+ # Only the acknowledging worker's stats include this failure (others skip increment when ack=false).
67
+ # Store so we can subtract it if another worker records success later.
68
+ store_error_report_delta(id, stat_delta) if stat_delta && stat_delta.any?
63
69
  end
64
-
65
- @queue.increment_test_failed if acknowledged == 1
66
- nil
70
+ # Return so caller can roll back local counter when not acknowledged
71
+ !!acknowledged
67
72
  end
68
73
 
69
- def record_success(id, stats: nil, skip_flaky_record: false)
70
- _, error_reports_deleted_count, requeued_count, _ = redis.multi do |transaction|
74
+ def record_success(id, skip_flaky_record: false)
75
+ acknowledged, error_reports_deleted_count, requeued_count, delta_json = redis.multi do |transaction|
71
76
  @queue.acknowledge(id, pipeline: transaction)
72
77
  transaction.hdel(key('error-reports'), id)
73
78
  transaction.hget(key('requeues-count'), id)
74
- record_stats(stats, pipeline: transaction)
79
+ transaction.hget(key('error-report-deltas'), id)
80
+ end
81
+ # When we're replacing a failure, subtract the (single) acknowledging worker's stat contribution
82
+ if error_reports_deleted_count.to_i > 0 && delta_json
83
+ apply_error_report_delta_correction(delta_json)
84
+ redis.hdel(key('error-report-deltas'), id)
75
85
  end
76
86
  record_flaky(id) if !skip_flaky_record && (error_reports_deleted_count.to_i > 0 || requeued_count.to_i > 0)
77
- nil
87
+ # Count this run when we ack'd or when we replaced a failure (so stats delta is applied)
88
+ !!(acknowledged || error_reports_deleted_count.to_i > 0)
78
89
  end
79
90
 
80
- def record_requeue(id, stats: nil)
81
- redis.pipelined do |pipeline|
82
- record_stats(stats, pipeline: pipeline)
91
+ def record_requeue(id)
92
+ true
93
+ end
94
+
95
+ def record_stats(stats = nil, pipeline: nil)
96
+ return unless stats
97
+ if pipeline
98
+ stats.each do |stat_name, stat_value|
99
+ pipeline.hset(key(stat_name), config.worker_id, stat_value)
100
+ pipeline.expire(key(stat_name), config.redis_ttl)
101
+ end
102
+ else
103
+ redis.pipelined do |p|
104
+ record_stats(stats, pipeline: p)
105
+ end
106
+ end
107
+ end
108
+
109
+ # Apply a delta to this worker's stats in Redis (HINCRBY). Use this instead of
110
+ # record_stats when recording per-test so we never overwrite and correction sticks.
111
+ def record_stats_delta(delta, pipeline: nil)
112
+ return if delta.nil? || delta.empty?
113
+ apply_delta = lambda do |p|
114
+ delta.each do |stat_name, value|
115
+ next unless value.is_a?(Numeric) || value.to_s.match?(/\A-?\d+\.?\d*\z/)
116
+ p.hincrbyfloat(key(stat_name), config.worker_id.to_s, value.to_f)
117
+ p.expire(key(stat_name), config.redis_ttl)
118
+ end
119
+ end
120
+ if pipeline
121
+ apply_delta.call(pipeline)
122
+ else
123
+ redis.pipelined { |p| apply_delta.call(p) }
83
124
  end
84
125
  end
85
126
 
@@ -130,16 +171,30 @@ module CI
130
171
 
131
172
  attr_reader :config, :redis
132
173
 
133
- def record_stats(stats, pipeline: redis)
134
- return unless stats
135
- stats.each do |stat_name, stat_value|
136
- pipeline.hset(key(stat_name), config.worker_id, stat_value)
137
- pipeline.expire(key(stat_name), config.redis_ttl)
138
- end
174
+ def key(*args)
175
+ ['build', config.build_id, *args].join(':')
139
176
  end
140
177
 
141
- def key(*args)
142
- KeyShortener.key(config.build_id, *args)
178
+ def store_error_report_delta(test_id, stat_delta)
179
+ # Only the acknowledging worker's stats include this test; store their delta for correction on success
180
+ payload = { 'worker_id' => config.worker_id.to_s }.merge(stat_delta)
181
+ redis.hset(key('error-report-deltas'), test_id, JSON.generate(payload))
182
+ redis.expire(key('error-report-deltas'), config.redis_ttl)
183
+ end
184
+
185
+ def apply_error_report_delta_correction(delta_json)
186
+ delta = JSON.parse(delta_json)
187
+ worker_id = delta.delete('worker_id')&.to_s
188
+ return if worker_id.nil? || worker_id.empty? || delta.empty?
189
+
190
+ redis.pipelined do |pipeline|
191
+ delta.each do |stat_name, value|
192
+ next unless value.is_a?(Numeric) || value.to_s.match?(/\A-?\d+\.?\d*\z/)
193
+
194
+ pipeline.hincrbyfloat(key(stat_name), worker_id, -value.to_f)
195
+ pipeline.expire(key(stat_name), config.redis_ttl)
196
+ end
197
+ end
143
198
  end
144
199
  end
145
200
  end
@@ -10,20 +10,32 @@ module CI
10
10
  @config = config
11
11
  end
12
12
 
13
- def record_error(payload, stats: nil)
13
+ def record_error(payload)
14
14
  redis.pipelined do |pipeline|
15
15
  pipeline.lpush(
16
16
  key('error-reports'),
17
17
  payload,
18
18
  )
19
19
  pipeline.expire(key('error-reports'), config.redis_ttl)
20
- record_stats(stats, pipeline: pipeline)
21
20
  end
22
21
  nil
23
22
  end
24
23
 
25
- def record_success(stats: nil)
26
- record_stats(stats)
24
+ def record_success
25
+ end
26
+
27
+ def record_stats(stats, pipeline: nil)
28
+ return unless stats
29
+ if pipeline
30
+ stats.each do |stat_name, stat_value|
31
+ pipeline.hset(key(stat_name), config.worker_id, stat_value)
32
+ pipeline.expire(key(stat_name), config.redis_ttl)
33
+ end
34
+ else
35
+ redis.pipelined do |p|
36
+ record_stats(stats, pipeline: p)
37
+ end
38
+ end
27
39
  end
28
40
 
29
41
  def record_warning(_,_)
@@ -52,15 +64,7 @@ module CI
52
64
  attr_reader :redis, :config
53
65
 
54
66
  def key(*args)
55
- KeyShortener.key(config.build_id, *args)
56
- end
57
-
58
- def record_stats(stats, pipeline: redis)
59
- return unless stats
60
- stats.each do |stat_name, stat_value|
61
- pipeline.hset(key(stat_name), config.worker_id, stat_value)
62
- pipeline.expire(key(stat_name), config.redis_ttl)
63
- end
67
+ ['build', config.build_id, *args].join(':')
64
68
  end
65
69
  end
66
70
  end
@@ -11,7 +11,6 @@ require 'ci/queue/redis/retry'
11
11
  require 'ci/queue/redis/supervisor'
12
12
  require 'ci/queue/redis/grind_supervisor'
13
13
  require 'ci/queue/redis/test_time_record'
14
- require 'ci/queue/redis/key_shortener'
15
14
 
16
15
  module CI
17
16
  module Queue
@@ -2,7 +2,7 @@
2
2
 
3
3
  module CI
4
4
  module Queue
5
- VERSION = '0.80.0'
5
+ VERSION = '0.82.0'
6
6
  DEV_SCRIPTS_ROOT = ::File.expand_path('../../../../../redis', __FILE__)
7
7
  RELEASE_SCRIPTS_ROOT = ::File.expand_path('../redis', __FILE__)
8
8
  end
@@ -38,28 +38,46 @@ module Minitest
38
38
  super
39
39
 
40
40
  self.total_time = Minitest.clock_time - start_time
41
- if test.requeued?
42
- self.requeues += 1
43
- elsif test.skipped?
44
- self.skips += 1
45
- elsif test.error?
46
- self.errors += 1
47
- elsif test.failure
48
- self.failures += 1
49
- end
41
+
42
+ # Determine what type of result this is and record it
43
+ test_id = "#{test.klass}##{test.name}"
44
+ delta = delta_for(test)
50
45
 
51
- stats = COUNTERS.zip(COUNTERS.map { |c| send(c) }).to_h
52
- if (test.failure || test.error?) && !test.skipped?
53
- build.record_error("#{test.klass}##{test.name}", dump(test), stats: stats)
46
+ acknowledged = if (test.failure || test.error?) && !test.skipped?
47
+ build.record_error(test_id, dump(test), stat_delta: delta)
54
48
  elsif test.requeued?
55
- build.record_requeue("#{test.klass}##{test.name}", stats: stats)
49
+ build.record_requeue(test_id)
56
50
  else
57
- build.record_success("#{test.klass}##{test.name}", stats: stats, skip_flaky_record: test.skipped?)
51
+ build.record_success(test_id, skip_flaky_record: test.skipped?)
52
+ end
53
+
54
+ if acknowledged
55
+ if (test.failure || test.error?) && !test.skipped?
56
+ test.error? ? self.errors += 1 : self.failures += 1
57
+ elsif test.requeued?
58
+ self.requeues += 1
59
+ elsif test.skipped?
60
+ self.skips += 1
61
+ end
62
+ # Apply delta to Redis (record_success returns true when ack'd or when we replaced a failure)
63
+ build.record_stats_delta(delta)
58
64
  end
59
65
  end
60
66
 
61
67
  private
62
68
 
69
+ def delta_for(test)
70
+ h = { 'assertions' => (test.assertions || 0).to_i, 'errors' => 0, 'failures' => 0, 'skips' => 0, 'requeues' => 0, 'total_time' => test.time.to_f }
71
+ if (test.failure || test.error?) && !test.skipped?
72
+ test.error? ? h['errors'] = 1 : h['failures'] = 1
73
+ elsif test.requeued?
74
+ h['requeues'] = 1
75
+ elsif test.skipped?
76
+ h['skips'] = 1
77
+ end
78
+ h
79
+ end
80
+
63
81
  def dump(test)
64
82
  ErrorReport.new(self.class.failure_formatter.new(test).to_h).dump
65
83
  end
@@ -13,11 +13,9 @@ module Minitest
13
13
  end
14
14
 
15
15
  def to_s
16
- [
17
- header,
18
- body,
19
- "\n"
20
- ].flatten.compact.join("\n")
16
+ s = +"#{header}\n#{body}\n\n"
17
+ s.encode!(Encoding::UTF_8, invalid: :replace, undef: :replace)
18
+ s
21
19
  end
22
20
 
23
21
  def to_h
@@ -32,12 +32,12 @@ module Minitest
32
32
  private
33
33
 
34
34
  def record_test(test)
35
- stats = self.class.counters
36
35
  if (test.failure || test.error?) && !test.skipped?
37
- build.record_error(dump(test), stats: stats)
36
+ build.record_error(dump(test))
38
37
  else
39
- build.record_success(stats: stats)
38
+ build.record_success
40
39
  end
40
+ build.record_stats(self.class.counters)
41
41
  end
42
42
 
43
43
  def increment_counter(test)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require 'optparse'
3
3
  require 'json'
4
+ require 'fileutils'
4
5
  require 'minitest/queue'
5
6
  require 'ci/queue'
6
7
  require 'digest/md5'
@@ -242,16 +243,16 @@ module Minitest
242
243
  puts
243
244
 
244
245
  File.write('log/test_order.log', failing_order.to_a.map(&:id).join("\n"))
245
-
246
+
246
247
  bisect_test_details = failing_order.to_a.map do |test|
247
248
  source_location = test.source_location
248
249
  file_path = source_location&.first || 'unknown'
249
250
  line_number = source_location&.last || -1
250
251
  "#{test.id} #{file_path}:#{line_number}"
251
252
  end
252
-
253
+
253
254
  File.write('log/bisect_test_details.log', bisect_test_details.join("\n"))
254
-
255
+
255
256
  exit! 0
256
257
  end
257
258
  end
@@ -336,8 +337,22 @@ module Minitest
336
337
  warnings = build.pop_warnings.map do |type, attributes|
337
338
  attributes.merge(type: type)
338
339
  end.compact
339
- File.open(queue_config.warnings_file, 'w') do |f|
340
- JSON.dump(warnings, f)
340
+
341
+ return if warnings.empty?
342
+
343
+ begin
344
+ # Ensure directory exists
345
+ dir = File.dirname(queue_config.warnings_file)
346
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
347
+
348
+ # Write each warning as a separate JSON line (JSONL format)
349
+ File.open(queue_config.warnings_file, 'a') do |f|
350
+ warnings.each do |warning|
351
+ f.puts(JSON.dump(warning))
352
+ end
353
+ end
354
+ rescue => error
355
+ STDERR.puts "Failed to write warnings: #{error.message}"
341
356
  end
342
357
  end
343
358
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ci-queue
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.80.0
4
+ version: 0.82.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean Boussier
@@ -203,7 +203,6 @@ files:
203
203
  - lib/ci/queue/redis/grind_record.rb
204
204
  - lib/ci/queue/redis/grind_supervisor.rb
205
205
  - lib/ci/queue/redis/heartbeat.lua
206
- - lib/ci/queue/redis/key_shortener.rb
207
206
  - lib/ci/queue/redis/monitor.rb
208
207
  - lib/ci/queue/redis/release.lua
209
208
  - lib/ci/queue/redis/requeue.lua
@@ -257,7 +256,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
257
256
  - !ruby/object:Gem::Version
258
257
  version: '0'
259
258
  requirements: []
260
- rubygems_version: 3.7.2
259
+ rubygems_version: 4.0.6
261
260
  specification_version: 4
262
261
  summary: Distribute tests over many workers using a queue
263
262
  test_files: []
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
- require 'digest/md5'
3
-
4
- module CI
5
- module Queue
6
- module Redis
7
- module KeyShortener
8
- # Suffix mapping for common key patterns
9
- SUFFIX_ALIASES = {
10
- 'running' => 'r',
11
- 'processed' => 'p',
12
- 'queue' => 'q',
13
- 'owners' => 'o',
14
- 'error-reports' => 'e',
15
- 'requeues-count' => 'rc',
16
- 'assertions' => 'a',
17
- 'errors' => 'er',
18
- 'failures' => 'f',
19
- 'skips' => 's',
20
- 'requeues' => 'rq',
21
- 'total_time' => 't',
22
- 'test_failed_count' => 'fc',
23
- 'completed' => 'c',
24
- 'master-status' => 'm',
25
- 'created-at' => 'ca',
26
- 'workers' => 'w',
27
- 'worker' => 'w',
28
- 'warnings' => 'wn',
29
- 'worker-errors' => 'we',
30
- 'flaky-reports' => 'fl',
31
- }.freeze
32
-
33
- # We're transforming the key to a shorter format to minimize network traffic.
34
- #
35
- # Strategy:
36
- # - Shorten prefix: 'b' instead of 'build'
37
- # - Hash UUID: 8-char MD5 instead of 36-char UUID
38
- # - Alias suffixes: single letters instead of full words
39
- #
40
- # Example:
41
- # build:unit:019aef0e-c010-433e-b706-c658d3c16372:running (55 bytes)
42
- # -> b:f03d3bef:r (13 bytes, 76% reduction)
43
-
44
- def self.key(build_id, *args)
45
- digest = Digest::MD5.hexdigest(build_id)[0..7]
46
- shortened_args = args.map { |arg| SUFFIX_ALIASES[arg] || arg }
47
-
48
- ['b', digest, *shortened_args].join(':')
49
- end
50
- end
51
- end
52
- end
53
- end