gitlab-test-balancing 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18b678cbe1614e6ceecbcccc59b3aa07ba7355844bda29ed27e5019f0d2cd20d
4
- data.tar.gz: 3d7b2fe157d3d4bcdbe8dc86346c178893e9cd7c72808ff69f767f6104e06b7d
3
+ metadata.gz: d0847c642c34fc38dbbb8fe6f468c7c906d04a5d756b08d92b0c1fd8f9b715f0
4
+ data.tar.gz: af8f00f37096a128eb0d0cd0ecc7b190ef15915d660ef64eb7c1c4a0825b9ba5
5
5
  SHA512:
6
- metadata.gz: 846ded0c9e1ed00910f0e6e7c2d8e2f23537124c7a60db890eea9f03b9cd0752b44d166e435ad70e765328b589cee5976fd1adeecee7eef2e095a15603f346ea
7
- data.tar.gz: 482582da2ba877905b12b563aea6b584a2279a212c16519f60ecc0897eee5f147ac019a930d363f438dc4fcef2b77a1bb8b099de31e997f4bb71529eb1a968ff
6
+ metadata.gz: 4ff4da41f151730ca3d72413bcbce9872c4f3947e0682196b1acdbd1f565d525626287bdd8e055020c6bd9e194b7e5facb0394c918eb9b38faf65437fdfff358
7
+ data.tar.gz: 2fa0983c324d7d90edcbfbf1d809f7de1a1adfbe3ceb92bf6635ea4ffce6196b0c296d1f5fceb5fdfdd82ecfa3ec741f5f0a864adcb98da6a67361306517562f
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'json'
6
+ require 'securerandom'
7
+ require 'uri'
8
+
9
+ module Gitlab
10
+ module TestBalancing
11
+ # HTTP client for the test balancing REST API.
12
+ #
13
+ # Talks to the `/job/test_balancing` endpoints on a GitLab instance,
14
+ # authenticated with a CI/CD job token. A parallel job seeds the job group's
15
+ # shared pending pool with its static test split, then repeatedly requests
16
+ # duration-budgeted batches of test splits until the pool is drained.
17
+ class Client
18
+ Error = Class.new(StandardError)
19
+
20
+ # Raised when the API responds 404, which the server returns when test
21
+ # balancing is unavailable for the project (feature flag off or unlicensed).
22
+ # Callers use this to fall back to a static split.
23
+ FeatureUnavailableError = Class.new(Error)
24
+
25
+ ServerError = Class.new(Error)
26
+ private_constant :ServerError
27
+
28
+ Result = Struct.new(:mode, :test_splits, keyword_init: true)
29
+
30
+ OPEN_TIMEOUT = 10
31
+ READ_TIMEOUT = 30
32
+ MAX_ATTEMPTS = 3
33
+ RETRY_BACKOFF = 2 # seconds, multiplied by the attempt number
34
+
35
+ # Transient errors worth retrying: network failures and 5xx responses.
36
+ # Other HTTP error responses (e.g. 404/422) are not retried.
37
+ RETRIABLE_ERRORS = [
38
+ Net::OpenTimeout,
39
+ Net::ReadTimeout,
40
+ Errno::ECONNRESET,
41
+ Errno::ECONNREFUSED,
42
+ Errno::EHOSTUNREACH,
43
+ Errno::ENETUNREACH,
44
+ SocketError,
45
+ IOError,
46
+ OpenSSL::SSL::SSLError,
47
+ ServerError
48
+ ].freeze
49
+
50
+ def initialize(api_url: ENV['CI_API_V4_URL'], job_token: ENV['CI_JOB_TOKEN'], logger: nil)
51
+ @api_url = api_url&.chomp('/')
52
+ @job_token = job_token
53
+ @logger = logger
54
+ @run_id = SecureRandom.uuid
55
+ @seq = 0
56
+ end
57
+
58
+ # test_splits: array of { path:, expected_duration: }. expected_duration may
59
+ # be omitted; the server defaults it. Returns a Result with mode ("seed" or
60
+ # "retry") and the test splits the node should run first.
61
+ def initialize_balancing(test_splits)
62
+ response = post('initialize', { test_splits: test_splits })
63
+
64
+ Result.new(mode: response['mode'], test_splits: symbolize_test_splits(response))
65
+ end
66
+
67
+ # Returns the next batch of test splits (array), empty when the queue is drained.
68
+ #
69
+ # Retries of this call reuse the run_id and seq, so the server returns the
70
+ # same batch if it already claimed one but the response never arrived, and
71
+ # ignores a late attempt from an earlier call.
72
+ def request
73
+ @seq += 1
74
+
75
+ symbolize_test_splits(post('request', { run_id: run_id, seq: @seq }))
76
+ end
77
+
78
+ private
79
+
80
+ attr_reader :api_url, :job_token, :logger, :run_id
81
+
82
+ def post(action, body)
83
+ uri = URI.parse("#{api_url}/job/test_balancing/#{action}")
84
+
85
+ request = Net::HTTP::Post.new(uri)
86
+ request['Content-Type'] = 'application/json'
87
+ request['JOB-TOKEN'] = job_token
88
+ request.body = JSON.dump(body)
89
+
90
+ response = with_retries(action) do
91
+ net_http_class.start(
92
+ uri.hostname, uri.port,
93
+ use_ssl: uri.scheme == 'https', open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT
94
+ ) do |http|
95
+ http.request(request).tap do |res|
96
+ raise ServerError, "#{res.code} #{res.body}" if res.is_a?(Net::HTTPServerError)
97
+ end
98
+ end
99
+ end
100
+
101
+ raise FeatureUnavailableError, "test balancing is unavailable (404) for #{action}" if
102
+ response.is_a?(Net::HTTPNotFound)
103
+
104
+ unless response.is_a?(Net::HTTPSuccess)
105
+ raise Error, "test balancing #{action} failed: #{response.code} #{response.body}"
106
+ end
107
+
108
+ JSON.parse(response.body)
109
+ end
110
+
111
+ # Retry the HTTP request on transient errors with linear backoff.
112
+ # Network failures and 5xx responses are retried; other HTTP error
113
+ # responses are handled by the caller and are not retried here.
114
+ def with_retries(action)
115
+ attempt = 0
116
+
117
+ begin
118
+ attempt += 1
119
+ yield
120
+ rescue *RETRIABLE_ERRORS => e
121
+ raise Error, "test balancing #{action} failed after #{MAX_ATTEMPTS} attempts: #{e.class}: #{e.message}" if
122
+ attempt >= MAX_ATTEMPTS
123
+
124
+ logger&.info("test balancing #{action} attempt #{attempt} failed (#{e.class}), retrying...")
125
+ sleep(RETRY_BACKOFF * attempt)
126
+ retry
127
+ end
128
+ end
129
+
130
+ def symbolize_test_splits(response)
131
+ Array(response['test_splits']).map { |split| split.transform_keys(&:to_sym) }
132
+ end
133
+
134
+ # When WebMock is loaded (e.g. the client runs inside a spec suite that
135
+ # enabled WebMock), use the original, un-stubbed Net::HTTP it saved so our
136
+ # own API calls hit the network without touching WebMock's global config or
137
+ # tripping the suite's request stubs. Falls back to plain Net::HTTP when
138
+ # WebMock is absent.
139
+ def net_http_class
140
+ if defined?(::WebMock::HttpLibAdapters::NetHttpAdapter::OriginalNetHTTP)
141
+ ::WebMock::HttpLibAdapters::NetHttpAdapter::OriginalNetHTTP
142
+ else
143
+ Net::HTTP
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gitlab/test_balancing/client'
4
+
5
+ module Gitlab
6
+ module TestBalancing
7
+ module Runner
8
+ # Drives a dynamic, API-balanced RSpec run for a single parallel node.
9
+ #
10
+ # Seeds this node's static test split into the shared pool via the API, then
11
+ # pulls and runs batches until the queue drains, keeping ONE reporter open
12
+ # for the whole lane so counts, the `Failures:` block and rerun snippets
13
+ # print once at the end.
14
+ #
15
+ # This class owns only the RSpec lifecycle and the queue-drain loop; it is
16
+ # project-agnostic. The caller computes the static split (however it likes)
17
+ # and passes it in. `#run` returns a Result and never calls `exit`, so the
18
+ # caller controls process termination.
19
+ #
20
+ # Each pulled batch is driven through the full `RSpec::Core::Runner#run_specs`
21
+ # lifecycle
22
+ # (https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/runner.rb),
23
+ # loading the batch's files together (the server caps a claim at a small
24
+ # number of files).
25
+ #
26
+ # The only deviation from `run_specs` is keeping ONE outer `reporter.report`
27
+ # across all batches, and `with_suite_hooks` wrapping the whole lane so
28
+ # before(:suite)/after(:suite) run once.
29
+ class Rspec
30
+ # status: process exit status (0 on success).
31
+ # unavailable: true when the feature was unavailable (404)
32
+ Result = Struct.new(:status, :unavailable, keyword_init: true) do
33
+ def passed?
34
+ status.zero?
35
+ end
36
+
37
+ def unavailable?
38
+ unavailable
39
+ end
40
+ end
41
+
42
+ NULL_LOGGER = Object.new.tap do |logger|
43
+ def logger.info(*); end
44
+ def logger.warn(*); end
45
+ end
46
+
47
+ # test_splits: array of { path:, expected_duration: } for this node.
48
+ # rspec_args: array of RSpec CLI args;
49
+ # any trailing file list (after `--`) is stripped since files are supplied dynamically per batch.
50
+ def initialize(test_splits:, rspec_args: [], client: nil, logger: NULL_LOGGER)
51
+ @test_splits = test_splits
52
+ @rspec_args = rspec_args
53
+ @logger = logger
54
+ @client = client || Client.new(logger: logger)
55
+ end
56
+
57
+ def run
58
+ first_batch = seed_and_claim_first_batch
59
+ return Result.new(status: 0, unavailable: true) if first_batch.nil?
60
+
61
+ boot_rspec
62
+ all_passed = drain_with_report(first_batch)
63
+
64
+ Result.new(status: exit_code(all_passed), unavailable: false)
65
+ ensure
66
+ # Persist even if the run raised (e.g. a profiler blowing up during
67
+ # teardown), so scripts can still --only-failures retry the specs that
68
+ # actually ran. Guarded by @config so an early return/crash is safe.
69
+ persist_example_statuses if @config
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :test_splits, :rspec_args, :client, :logger
75
+
76
+ def seed_and_claim_first_batch
77
+ logger.info "Seeding #{test_splits.size} test splits into the test balancing pool."
78
+ result = client.initialize_balancing(test_splits)
79
+ logger.info "Test balancing initialized in '#{result.mode}' mode."
80
+
81
+ result.test_splits
82
+ rescue Client::FeatureUnavailableError
83
+ logger.info 'Test balancing is unavailable, falling back to static split.'
84
+ nil
85
+ end
86
+
87
+ def boot_rspec
88
+ require 'rspec/core'
89
+
90
+ @options = ::RSpec::Core::ConfigurationOptions.new(base_rspec_args)
91
+ ::RSpec::Core::Runner.new(@options).configure($stderr, $stdout)
92
+
93
+ @config = ::RSpec.configuration
94
+ @world = ::RSpec.world
95
+
96
+ # We need to alter world.example_groups for each iteration and world.all_examples
97
+ # only ever reflects the current batch. So we keep our own record of executed examples.
98
+ @executed_examples = []
99
+ end
100
+
101
+ def drain_with_report(first_batch)
102
+ # One report cycle for the entire lane. The true example count is unknown
103
+ # up front (files arrive dynamically), so we pass 0 as the expected count;
104
+ # progress/summary formatters treat this as "unknown total".
105
+ @config.reporter.report(0) do |reporter|
106
+ @reporter = reporter
107
+ @config.with_suite_hooks do # before(:suite)/after(:suite) run once for the lane
108
+ drain_queue(first_batch)
109
+ end
110
+ end
111
+ end
112
+
113
+ # Run the first batch, then keep requesting and running batches until the
114
+ # queue is drained. Returns true when every example across every batch passed.
115
+ #
116
+ # Stops pulling once RSpec sets world.wants_to_quit (fail-fast limit met or
117
+ # an interrupt), mirroring how Runner#run guards run_specs.
118
+ def drain_queue(first_batch)
119
+ all_passed = true
120
+ batch = first_batch
121
+
122
+ loop do
123
+ all_passed = false unless run_batch(batch)
124
+
125
+ break if @world.wants_to_quit
126
+
127
+ batch = client.request
128
+ break if batch.empty?
129
+ end
130
+
131
+ all_passed
132
+ end
133
+
134
+ # Run a whole pulled batch through the full `run_specs` lifecycle against
135
+ # the shared reporter. Returns true if every example in the batch passed.
136
+ def run_batch(batch)
137
+ paths = batch.map { |split| split[:path] }
138
+ expected_duration = batch.sum { |split| split[:expected_duration].to_f }
139
+ logger.info "Pulled #{paths.size} test splits (expected duration: #{expected_duration.round(2)}s) " \
140
+ "from queue:\n#{paths.join("\n")}"
141
+
142
+ @world.reset
143
+
144
+ # Setup a fresh FilterManager for every batch as
145
+ # Rspec's announce_filters may mutate the filter manager when
146
+ # run_all_when_everything_filtered is enabled.
147
+ filter_manager = ::RSpec::Core::FilterManager.new
148
+ @options.configure_filter_manager(filter_manager)
149
+ @config.filter_manager = filter_manager
150
+
151
+ @config.files_or_directories_to_run = paths
152
+ @config.load_spec_files
153
+ @world.announce_filters
154
+
155
+ groups = @world.ordered_example_groups
156
+ @world.example_count(groups) # force filtered_examples computation, as run_specs does
157
+
158
+ passed = groups.map { |group| group.run(@reporter) }.all?
159
+
160
+ # Capture this batch's examples before the next iteration's world.reset
161
+ @executed_examples.concat(@world.all_examples)
162
+
163
+ passed
164
+ end
165
+
166
+ # Mirrors RSpec::Core::Runner#exit_code: errors raised outside of examples
167
+ # (e.g. a before(:suite) hook, or "errors occurred outside of examples")
168
+ # fail the run even when every example passed.
169
+ def exit_code(examples_passed)
170
+ return @config.error_exit_code || @config.failure_exit_code if @world.non_example_failure
171
+ return @config.failure_exit_code unless examples_passed
172
+
173
+ 0
174
+ end
175
+
176
+ # Reproduces RSpec::Core::Runner#persist_example_statuses, which the queue
177
+ # loop bypasses (it never calls Runner#run). Writes the accumulated example
178
+ # statuses to config.example_status_persistence_file_path in RSpec's native
179
+ # format so --only-failures retries work unchanged. ExampleStatusPersister
180
+ # merges with, and file-locks, the existing file, so a single end-of-lane
181
+ # call is safe.
182
+ def persist_example_statuses
183
+ path = @config.example_status_persistence_file_path
184
+ return if path.nil? || @executed_examples.empty?
185
+
186
+ ::RSpec::Core::ExampleStatusPersister.persist(@executed_examples, path)
187
+ rescue SystemCallError => e
188
+ logger.warn "Could not write example statuses to #{path}: #{e.inspect}"
189
+ end
190
+
191
+ def base_rspec_args
192
+ idx = rspec_args.index('--')
193
+ idx ? rspec_args[0...idx] : rspec_args
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module TestBalancing
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -1 +1,9 @@
1
- raise "Reserved for GitLab"
1
+ # rubocop:disable Naming/FileName -- required to match the gem name for the default require
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'gitlab/test_balancing/version'
6
+ require_relative 'gitlab/test_balancing/client'
7
+ require_relative 'gitlab/test_balancing/runner/rspec'
8
+
9
+ # rubocop:enable Naming/FileName
metadata CHANGED
@@ -1,15 +1,73 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-test-balancing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
- - GitLab
7
+ - GitLab Engineers
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
12
- description: Reserved for GitLab
11
+ date: 2026-11-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: gitlab-styles
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 13.0.1
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 13.0.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: webmock
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.18'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.18'
55
+ - !ruby/object:Gem::Dependency
56
+ name: webrick
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.8'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.8'
69
+ description: Distributes tests across the nodes of a parallel CI/CD job using the
70
+ GitLab test balancing API
13
71
  email:
14
72
  - engineering@gitlab.com
15
73
  executables: []
@@ -17,11 +75,15 @@ extensions: []
17
75
  extra_rdoc_files: []
18
76
  files:
19
77
  - lib/gitlab-test-balancing.rb
20
- homepage: https://gitlab.com/gitlab-org/gitlab/-/tree/master/gems/gitlab-test-balancing
78
+ - lib/gitlab/test_balancing/client.rb
79
+ - lib/gitlab/test_balancing/runner/rspec.rb
80
+ - lib/gitlab/test_balancing/version.rb
81
+ homepage: https://gitlab.com/gitlab-org/ruby/gems/gitlab-test-balancing
21
82
  licenses:
22
83
  - MIT
23
84
  metadata:
24
85
  rubygems_mfa_required: 'true'
86
+ post_install_message:
25
87
  rdoc_options: []
26
88
  require_paths:
27
89
  - lib
@@ -29,14 +91,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
29
91
  requirements:
30
92
  - - ">="
31
93
  - !ruby/object:Gem::Version
32
- version: '3.0'
94
+ version: '3.2'
33
95
  required_rubygems_version: !ruby/object:Gem::Requirement
34
96
  requirements:
35
97
  - - ">="
36
98
  - !ruby/object:Gem::Version
37
99
  version: '0'
38
100
  requirements: []
39
- rubygems_version: 4.0.20
101
+ rubygems_version: 3.5.22
102
+ signing_key:
40
103
  specification_version: 4
41
- summary: Reserved for GitLab
104
+ summary: Client for the GitLab test balancing API
42
105
  test_files: []