Skip to content

Commit cdee7ac

Browse files
Initial test version of lazy loading
1 parent 3de93c6 commit cdee7ac

File tree

15 files changed

+1517
-35
lines changed

15 files changed

+1517
-35
lines changed

ruby/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,31 @@ minitest-queue --queue redis://example.com run -Itest test/**/*_test.rb
3838

3939
Additionally you can configure the requeue settings (see main README) with `--max-requeues` and `--requeue-tolerance`.
4040

41+
#### Lazy Loading (Minitest only)
42+
43+
For large test suites, you can enable lazy loading to reduce memory usage on worker nodes. With lazy loading enabled:
44+
45+
- The **leader** worker loads all test files to build a manifest mapping test classes to their source files
46+
- **Consumer** workers only load test files on-demand as they pick up tests from the queue
47+
48+
This can provide significant memory savings (40-80%+) for workers that don't need to run the entire test suite.
49+
50+
```bash
51+
minitest-queue --queue redis://example.com run \
52+
--lazy-load \
53+
--test-helpers test/test_helper.rb \
54+
-Itest test/**/*_test.rb
55+
```
56+
57+
Options:
58+
- `--lazy-load`: Enable lazy loading mode
59+
- `--test-helpers`: Comma-separated list of helper files to load before tests (e.g., `test/test_helper.rb`)
60+
61+
You can also enable lazy loading via environment variables:
62+
- `CI_QUEUE_LAZY_LOAD=true`
63+
- `CI_QUEUE_TEST_HELPERS=test/test_helper.rb`
64+
65+
**Note:** Lazy loading is currently only available for Minitest.
4166

4267
If you'd like to centralize the error reporting you can do so with:
4368

ruby/lib/ci/queue.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
require 'ci/queue/file'
1515
require 'ci/queue/grind'
1616
require 'ci/queue/bisect'
17+
require 'ci/queue/lazy_loader'
1718

1819
module CI
1920
module Queue

ruby/lib/ci/queue/configuration.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ class Configuration
66
attr_accessor :requeue_tolerance, :namespace, :failing_test, :statsd_endpoint
77
attr_accessor :max_test_duration, :max_test_duration_percentile, :track_test_duration
88
attr_accessor :max_test_failed, :redis_ttl, :warnings_file, :debug_log, :max_missed_heartbeat_seconds
9+
attr_accessor :lazy_load, :test_helpers
910
attr_reader :circuit_breakers
1011
attr_writer :seed, :build_id
1112
attr_writer :queue_init_timeout, :report_timeout, :inactive_workers_timeout
@@ -22,6 +23,8 @@ def from_env(env)
2223
debug_log: env['CI_QUEUE_DEBUG_LOG'],
2324
max_requeues: env['CI_QUEUE_MAX_REQUEUES']&.to_i || 0,
2425
requeue_tolerance: env['CI_QUEUE_REQUEUE_TOLERANCE']&.to_f || 0,
26+
lazy_load: env['CI_QUEUE_LAZY_LOAD'] == 'true',
27+
test_helpers: env['CI_QUEUE_TEST_HELPERS'],
2528
)
2629
end
2730

@@ -46,7 +49,8 @@ def initialize(
4649
grind_count: nil, max_duration: nil, failure_file: nil, max_test_duration: nil,
4750
max_test_duration_percentile: 0.5, track_test_duration: false, max_test_failed: nil,
4851
queue_init_timeout: nil, redis_ttl: 8 * 60 * 60, report_timeout: nil, inactive_workers_timeout: nil,
49-
export_flaky_tests_file: nil, warnings_file: nil, debug_log: nil, max_missed_heartbeat_seconds: nil)
52+
export_flaky_tests_file: nil, warnings_file: nil, debug_log: nil, max_missed_heartbeat_seconds: nil,
53+
lazy_load: false, test_helpers: nil)
5054
@build_id = build_id
5155
@circuit_breakers = [CircuitBreaker::Disabled]
5256
@failure_file = failure_file
@@ -73,6 +77,8 @@ def initialize(
7377
@warnings_file = warnings_file
7478
@debug_log = debug_log
7579
@max_missed_heartbeat_seconds = max_missed_heartbeat_seconds
80+
@lazy_load = lazy_load
81+
@test_helpers = test_helpers
7682
end
7783

7884
def queue_init_timeout
@@ -118,6 +124,16 @@ def build_id
118124
def global_max_requeues(tests_count)
119125
(tests_count * Float(requeue_tolerance)).ceil
120126
end
127+
128+
def lazy_load?
129+
@lazy_load
130+
end
131+
132+
def test_helper_paths
133+
return [] unless @test_helpers
134+
135+
@test_helpers.split(',').map(&:strip)
136+
end
121137
end
122138
end
123139
end

ruby/lib/ci/queue/lazy_loader.rb

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# frozen_string_literal: true
2+
3+
require 'json'
4+
require 'set'
5+
6+
module CI
7+
module Queue
8+
class LazyLoadError < StandardError; end
9+
10+
# LazyLoader handles on-demand loading of test files based on a manifest
11+
# that maps class names to their source file paths.
12+
#
13+
# The manifest is a Hash mapping class names (e.g., "MyModule::MyTest") to
14+
# absolute file paths where those classes are defined.
15+
#
16+
# Test IDs follow the format "ClassName#method_name" (e.g., "MyTest#test_foo").
17+
class LazyLoader
18+
attr_reader :loaded_files
19+
20+
def initialize
21+
@loaded_files = Set.new
22+
@manifest = {}
23+
end
24+
25+
# Build manifest from loaded tests
26+
# Returns a hash mapping class_name -> file_path
27+
# Handles both Minitest::Queue::SingleExample and regular test objects
28+
def self.build_manifest(tests)
29+
manifest = {}
30+
tests.each do |test|
31+
# SingleExample has `runnable` (the class) and `method_name`
32+
# Regular test objects have `class` and `name`
33+
if test.respond_to?(:runnable)
34+
# Minitest::Queue::SingleExample
35+
class_name = test.runnable.name
36+
source_location = test.source_location&.first
37+
else
38+
# Regular test object
39+
class_name = test.class.name
40+
method_name = test.respond_to?(:method_name) ? test.method_name : test.name
41+
source_location = test.method(method_name).source_location&.first rescue nil
42+
end
43+
44+
next if class_name.nil? || source_location.nil?
45+
46+
# Warn about duplicate class names - only one file path will be used
47+
if manifest.key?(class_name) && manifest[class_name] != source_location
48+
warn "[ci-queue] WARNING: Duplicate class name '#{class_name}' found in multiple files:\n" \
49+
" - #{manifest[class_name]}\n" \
50+
" - #{source_location}\n" \
51+
"Only one will be used for lazy loading. Rename one class to avoid test failures."
52+
end
53+
54+
manifest[class_name] = source_location
55+
end
56+
manifest
57+
end
58+
59+
# Store manifest in Redis
60+
# key: Redis key to store the manifest
61+
def store_manifest(redis, key, manifest, ttl:)
62+
return if manifest.empty?
63+
64+
redis.hset(key, manifest)
65+
redis.expire(key, ttl)
66+
end
67+
68+
# Fetch manifest from Redis with retry logic
69+
# key: Redis key where manifest is stored
70+
# retries: number of retries if manifest is empty
71+
def fetch_manifest(redis, key, retries: 3, retry_delay: 0.5)
72+
return @manifest unless @manifest.empty?
73+
74+
retries.times do |attempt|
75+
@manifest = redis.hgetall(key)
76+
break unless @manifest.empty?
77+
78+
sleep(retry_delay * (attempt + 1)) if attempt < retries - 1
79+
end
80+
81+
if @manifest.empty?
82+
raise LazyLoadError, "Failed to fetch manifest from Redis after #{retries} attempts. " \
83+
"The leader may not have finished populating the queue."
84+
end
85+
86+
@manifest
87+
end
88+
89+
# Set manifest directly (e.g., from leader)
90+
def set_manifest(manifest)
91+
@manifest = manifest
92+
end
93+
94+
# Load test class if not already loaded
95+
# Returns the test runnable instance
96+
def load_test(class_name, method_name)
97+
load_class(class_name)
98+
instantiate_test(class_name, method_name)
99+
end
100+
101+
# Load a class from the manifest
102+
def load_class(class_name)
103+
file_path = @manifest[class_name]
104+
raise LazyLoadError, "No manifest entry for #{class_name}" unless file_path
105+
106+
# Use `load` instead of `require` - classes may be partially defined
107+
# (constant exists but methods missing due to autoloader/parent class).
108+
# Ruby's require checks $LOADED_FEATURES and may skip re-execution,
109+
# whereas load always executes the file.
110+
# We track loaded files ourselves to prevent duplicate loading.
111+
unless @loaded_files.include?(file_path)
112+
load(file_path)
113+
@loaded_files.add(file_path)
114+
end
115+
end
116+
117+
# Find a class by name using const_get
118+
# Raises LazyLoadError with helpful message if class not found
119+
def find_class(class_name)
120+
class_name.split('::').reduce(Object) do |ns, const|
121+
ns.const_get(const, false)
122+
end
123+
rescue NameError => e
124+
file_path = @manifest[class_name]
125+
raise LazyLoadError, "Class #{class_name} not found after loading #{file_path}. " \
126+
"The file may not define the expected class. Original error: #{e.message}"
127+
end
128+
129+
# Instantiate a test runnable
130+
def instantiate_test(class_name, method_name)
131+
klass = find_class(class_name)
132+
klass.new(method_name)
133+
end
134+
135+
# Parse a test identifier into class_name and method_name
136+
def self.parse_test_id(test_id)
137+
# Test IDs are in format "ClassName#method_name" or "Module::ClassName#method_name"
138+
class_name, method_name = test_id.split('#', 2)
139+
[class_name, method_name]
140+
end
141+
142+
# Build a test identifier from class_name and method_name
143+
def self.build_test_id(class_name, method_name)
144+
"#{class_name}##{method_name}"
145+
end
146+
147+
def files_loaded_count
148+
@loaded_files.size
149+
end
150+
end
151+
end
152+
end

ruby/lib/ci/queue/redis/worker.rb

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22
require 'ci/queue/static'
3+
require 'ci/queue/lazy_loader'
34
require 'concurrent/set'
45

56
module CI
@@ -18,6 +19,7 @@ class Worker < Base
1819
def initialize(redis, config)
1920
@reserved_tests = Concurrent::Set.new
2021
@shutdown_required = false
22+
@lazy_loader = LazyLoader.new if config.lazy_load?
2123
super(redis, config)
2224
end
2325

@@ -32,8 +34,67 @@ def populate(tests, random: Random.new)
3234
self
3335
end
3436

37+
# Populate queue with lazy loading support
38+
# Only the leader loads all test files and builds the manifest
39+
# Workers load files on-demand when they claim tests
40+
def populate_lazy(test_files:, random:, config:)
41+
@lazy_load_mode = true
42+
@lazy_loader ||= LazyLoader.new
43+
44+
push do
45+
begin
46+
# This block only runs on master - load files and build manifest
47+
test_files.each do |f|
48+
require(f)
49+
rescue LoadError => e
50+
raise LazyLoadError, "Failed to load test file #{f}: #{e.message}"
51+
end
52+
53+
tests = Minitest.loaded_tests
54+
if tests.empty?
55+
raise LazyLoadError, "No tests found after loading #{test_files.size} test files. " \
56+
"Ensure test files define Minitest::Test subclasses."
57+
end
58+
59+
# Build and store manifest
60+
manifest = LazyLoader.build_manifest(tests)
61+
@lazy_loader.set_manifest(manifest)
62+
@lazy_loader.store_manifest(redis, key('manifest'), manifest, ttl: config.redis_ttl)
63+
64+
# Count files loaded for metrics
65+
source_files = Set.new
66+
tests.each do |test|
67+
source_location = test.source_location&.first
68+
source_files.add(source_location) if source_location
69+
end
70+
@files_loaded_by_leader = source_files.size
71+
72+
puts "Leader loaded #{@files_loaded_by_leader} test files, found #{tests.size} tests."
73+
74+
# Return shuffled test IDs
75+
Queue.shuffle(tests, random).map(&:id)
76+
rescue LazyLoadError
77+
raise
78+
rescue => error
79+
build.report_worker_error(error)
80+
raise LazyLoadError, "Failed to build manifest: #{error.class}: #{error.message}"
81+
end
82+
end
83+
self
84+
end
85+
3586
def populated?
36-
!!defined?(@index)
87+
!!defined?(@index) || @lazy_load_mode
88+
end
89+
90+
def lazy_load?
91+
@lazy_load_mode || config.lazy_load?
92+
end
93+
94+
def files_loaded_count
95+
return 0 unless @lazy_loader
96+
97+
@lazy_loader.files_loaded_count
3798
end
3899

39100
def shutdown!
@@ -52,11 +113,13 @@ def master?
52113

53114
def poll
54115
wait_for_master
116+
fetch_manifest_for_lazy_load if lazy_load?
55117
attempt = 0
56118
until shutdown_required? || config.circuit_breakers.any?(&:open?) || exhausted? || max_test_failed?
57-
if test = reserve
119+
if test_id = reserve
58120
attempt = 0
59-
yield index.fetch(test)
121+
example = lazy_load? ? load_test_lazily(test_id) : index.fetch(test_id)
122+
yield example
60123
else
61124
# Adding exponential backoff to avoid hammering Redis
62125
# we just stay online here in case a test gets retried or times out so we can afford to wait
@@ -72,6 +135,22 @@ def poll
72135
rescue *CONNECTION_ERRORS
73136
end
74137

138+
def fetch_manifest_for_lazy_load
139+
return unless @lazy_loader
140+
return if @manifest_fetched
141+
142+
@lazy_loader.fetch_manifest(redis, key('manifest'))
143+
@manifest_fetched = true
144+
end
145+
146+
def load_test_lazily(test_id)
147+
class_name, method_name = LazyLoader.parse_test_id(test_id)
148+
@lazy_loader.load_class(class_name)
149+
# Return a SingleExample like the index would
150+
runnable = @lazy_loader.find_class(class_name)
151+
Minitest::Queue::SingleExample.new(runnable, method_name)
152+
end
153+
75154
if ::Redis.method_defined?(:exists?)
76155
def retrying?
77156
redis.exists?(key('worker', worker_id, 'queue'))
@@ -208,8 +287,11 @@ def try_to_reserve_lost_test
208287
lost_test
209288
end
210289

211-
def push(tests)
212-
@total = tests.size
290+
# Push test IDs to the queue.
291+
# Can be called with test IDs directly, or with a block that returns test IDs.
292+
# The block form is used for lazy loading where only the master loads test files.
293+
def push(tests = nil, &block)
294+
@total = tests.size if tests
213295

214296
# We set a unique value (worker_id) and read it back to make "SET if Not eXists" idempotent in case of a retry.
215297
value = key('setup', worker_id)
@@ -219,6 +301,10 @@ def push(tests)
219301
end
220302

221303
if @master = (value == status)
304+
# If block given, call it to get test IDs (used for lazy loading)
305+
tests = yield if block_given?
306+
@total = tests.size
307+
222308
puts "Worker elected as leader, pushing #{@total} tests to the queue."
223309
puts
224310

0 commit comments

Comments
 (0)