@@ -26,12 +26,25 @@ def distributed?
2626 end
2727
2828 def populate ( tests , random : Random . new )
29- @index = tests . map { |t | [ t . id , t ] } . to_h
29+ if config . batch_upload
30+ @index = { }
31+ @source_files_loaded = Set . new
32+ else
33+ @index = tests . map { |t | [ t . id , t ] } . to_h
34+ end
3035 tests = Queue . shuffle ( tests , random )
3136 push ( tests . map ( &:id ) )
3237 self
3338 end
3439
40+ def populate_from_files ( file_paths , random : Random . new )
41+ @file_paths = file_paths . sort
42+ @index = { }
43+ @source_files_loaded = Set . new
44+ push_files_in_batches ( @file_paths , random )
45+ self
46+ end
47+
3548 def populated?
3649 !!defined? ( @index )
3750 end
@@ -54,9 +67,17 @@ def poll
5467 wait_for_master
5568 attempt = 0
5669 until shutdown_required? || config . circuit_breakers . any? ( &:open? ) || exhausted? || max_test_failed?
57- if test = reserve
70+ if test_id = reserve
5871 attempt = 0
59- yield index . fetch ( test )
72+
73+ # Lazy load test if needed (batch mode)
74+ test = if config . batch_upload && !@index . key? ( test_id )
75+ @index [ test_id ] = build_index_entry ( test_id )
76+ else
77+ index . fetch ( test_id )
78+ end
79+
80+ yield test
6081 else
6182 # Adding exponential backoff to avoid hammering Redis
6283 # we just stay online here in case a test gets retried or times out so we can afford to wait
@@ -153,6 +174,120 @@ def release!
153174
154175 attr_reader :index
155176
177+ def push_files_in_batches ( file_paths , random )
178+ #Elect master (existing logic)
179+ value = key ( 'setup' , worker_id )
180+ _ , status = redis . pipelined do |pipeline |
181+ pipeline . set ( key ( 'master-status' ) , value , nx : true )
182+ pipeline . get ( key ( 'master-status' ) )
183+ end
184+
185+ if @master = ( value == status )
186+ puts "Worker elected as leader, loading and pushing tests in batches..."
187+ puts
188+
189+ # Set status to 'streaming' to signal workers can start
190+ redis . set ( key ( 'master-status' ) , 'streaming' )
191+
192+ # Group files into batches based on batch_size
193+ # Since we're batching by files, calculate files per batch to approximate tests per batch
194+ files_per_batch = [ config . batch_size / 10 , 1 ] . max # Estimate ~10 tests per file
195+
196+ all_tests = [ ]
197+ tests_uploaded = 0
198+
199+ attempts = 0
200+ duration = measure do
201+ file_paths . each_slice ( files_per_batch ) . with_index do |file_batch , batch_num |
202+ # Load files in this batch
203+ batch_tests = [ ]
204+ file_batch . each do |file_path |
205+ abs_path = ::File . expand_path ( file_path )
206+ require abs_path
207+ @source_files_loaded . add ( abs_path )
208+ end
209+
210+ # Extract tests from newly loaded files
211+ if defined? ( Minitest )
212+ Minitest ::Test . runnables . each do |runnable |
213+ runnable . runnable_methods . each do |method_name |
214+ test = Minitest ::Queue ::SingleExample . new ( runnable , method_name )
215+ unless @index . key? ( test . id )
216+ batch_tests << test
217+ @index [ test . id ] = test
218+ end
219+ end
220+ end
221+ end
222+
223+ # Shuffle tests in this batch
224+ batch_tests = Queue . shuffle ( batch_tests , random )
225+
226+ unless batch_tests . empty?
227+ # Extract metadata
228+ test_ids = [ ]
229+ metadata = { }
230+
231+ batch_tests . each do |test |
232+ test_ids << test . id
233+ if test . respond_to? ( :source_location ) && ( location = test . source_location )
234+ metadata [ test . id ] = location [ 0 ] # file path
235+ end
236+ end
237+
238+ # Upload batch to Redis
239+ with_redis_timeout ( 5 ) do
240+ redis . without_reconnect do
241+ redis . pipelined do |pipeline |
242+ pipeline . lpush ( key ( 'queue' ) , test_ids )
243+ pipeline . mapped_hmset ( key ( 'test-metadata' ) , metadata ) unless metadata . empty?
244+ pipeline . incr ( key ( 'batch-count' ) )
245+ pipeline . expire ( key ( 'queue' ) , config . redis_ttl )
246+ pipeline . expire ( key ( 'test-metadata' ) , config . redis_ttl )
247+ pipeline . expire ( key ( 'batch-count' ) , config . redis_ttl )
248+ end
249+ end
250+ rescue ::Redis ::BaseError => error
251+ if attempts < 3
252+ puts "Retrying batch upload... (#{ error } )"
253+ attempts += 1
254+ retry
255+ end
256+ raise
257+ end
258+
259+ tests_uploaded += test_ids . size
260+
261+ # Progress reporting
262+ if ( batch_num + 1 ) % 10 == 0 || batch_num == 0
263+ puts "Uploaded #{ tests_uploaded } tests from #{ ( batch_num + 1 ) * files_per_batch } files..."
264+ end
265+ end
266+
267+ all_tests . concat ( batch_tests )
268+ end
269+ end
270+
271+ @total = all_tests . size
272+
273+ # Mark upload complete
274+ redis . multi do |transaction |
275+ transaction . set ( key ( 'total' ) , @total )
276+ transaction . set ( key ( 'master-status' ) , 'ready' )
277+ transaction . expire ( key ( 'total' ) , config . redis_ttl )
278+ transaction . expire ( key ( 'master-status' ) , config . redis_ttl )
279+ end
280+
281+ puts
282+ puts "Finished pushing #{ @total } tests to the queue in #{ duration . round ( 2 ) } s."
283+ end
284+
285+ register
286+ redis . expire ( key ( 'workers' ) , config . redis_ttl )
287+ rescue *CONNECTION_ERRORS
288+ raise if @master
289+ end
290+
156291 def reserved_tests
157292 @reserved_tests ||= Concurrent ::Set . new
158293 end
@@ -161,6 +296,54 @@ def worker_id
161296 config . worker_id
162297 end
163298
299+ def build_index_entry ( test_id )
300+ # Try to load from metadata
301+ file_path = redis . hget ( key ( 'test-metadata' ) , test_id )
302+
303+ if file_path && !@source_files_loaded . include? ( file_path )
304+ # Lazy load the test file
305+ require_test_file ( file_path )
306+ @source_files_loaded . add ( file_path )
307+ end
308+
309+ # Find the test in loaded runnables
310+ find_test_object ( test_id )
311+ end
312+
313+ def require_test_file ( file_path )
314+ # Make path absolute if needed
315+ abs_path = if file_path . start_with? ( '/' )
316+ file_path
317+ else
318+ ::File . expand_path ( file_path )
319+ end
320+
321+ # Require the file
322+ require abs_path
323+ rescue LoadError => e
324+ # Log warning but continue
325+ warn "Warning: Could not load test file #{ file_path } : #{ e . message } "
326+ end
327+
328+ def find_test_object ( test_id )
329+ # For Minitest
330+ if defined? ( Minitest )
331+ Minitest ::Test . runnables . each do |runnable |
332+ runnable . runnable_methods . each do |method_name |
333+ candidate_id = "#{ runnable } ##{ method_name } "
334+ if candidate_id == test_id
335+ return Minitest ::Queue ::SingleExample . new ( runnable , method_name )
336+ end
337+ end
338+ end
339+ end
340+
341+ # Fallback: create a test object that will report an error
342+ warn "Warning: Test #{ test_id } not found after loading file. Ensure all dependencies are explicitly required in test_helper.rb"
343+ # Return nil and let index.fetch handle the KeyError
344+ nil
345+ end
346+
164347 def raise_on_mismatching_test ( test )
165348 unless reserved_tests . delete? ( test )
166349 raise ReservationError , "Acknowledged #{ test . inspect } but only #{ reserved_tests . map ( &:inspect ) . join ( ", " ) } reserved"
0 commit comments