33import functools
44import itertools
55import logging
6+ import pathlib
67import typing
78
89from .utils import console_log_level
@@ -15,6 +16,7 @@ class TestResult:
1516 """
1617
1718 __test__ = False # stop pytest gathering this
19+ host_output_directory : pathlib .Path
1820 result : bool
1921 stdouterr : str
2022
@@ -80,7 +82,7 @@ def container_search(
8082 logger : logging .Logger ,
8183 skip_precondition_checks : bool ,
8284 threshold_days : int ,
83- ):
85+ ) -> typing . Tuple [ datetime . date , datetime . date ] :
8486 adjust = functools .partial (
8587 adjust_date , logger = logger , container_exists = container_exists
8688 )
@@ -197,36 +199,29 @@ def __call__(self, *, commits: typing.Dict[str, str]) -> TestResult:
197199 ...
198200
199201
200- def _first (xs ):
202+ T = typing .TypeVar ("T" )
203+ U = typing .TypeVar ("U" )
204+ FlatCommitDict = typing .Tuple [typing .Tuple [str , str ], ...]
205+
206+
207+ def _first (xs : typing .Iterable [T ]) -> T :
201208 return next (iter (xs ))
202209
203210
204- def _not_first (d ) :
211+ def _not_first (d : typing . Dict [ T , U ]) -> typing . Iterable [ typing . Tuple [ T , U ]] :
205212 return itertools .islice (d .items (), 1 , None )
206213
207214
208- def commit_search (
215+ def _commit_search (
209216 * ,
210217 commits : typing .OrderedDict [
211218 str , typing .Sequence [typing .Tuple [str , datetime .datetime ]]
212219 ],
213220 build_and_test : BuildAndTest ,
214221 logger : logging .Logger ,
215222 skip_precondition_checks : bool ,
216- ):
217- """
218- Bisect a failure back to a single commit.
219-
220- Arguments:
221- commits: *ordered* dictionary of commit sequences for different software
222- packages, e.g. commits["jax"][0] is (hash, date) of the passing JAX
223- commit. The ordering of packages has implications for precisely how
224- the triage proceeds.
225- build_and_test: callable that tests if a given vector of commits passes
226- logger: instance to log output to
227- skip_precondition_checks: if True, some tests that should pass/fail by
228- construction are skipped
229- """
223+ result_cache : typing .Dict [FlatCommitDict , TestResult ],
224+ ) -> typing .Tuple [typing .Dict [str , str ], TestResult , typing .Optional [TestResult ]]:
230225 assert all (len (commit_list ) for commit_list in commits .values ()), (
231226 "Not enough commits: need at least one commit for each package" ,
232227 commits ,
@@ -236,6 +231,11 @@ def commit_search(
236231 commits ,
237232 )
238233
234+ def _cache_key (
235+ commits : typing .Dict [str , str ],
236+ ) -> FlatCommitDict :
237+ return tuple (sorted (commits .items ()))
238+
239239 if skip_precondition_checks :
240240 logger .info ("Skipping check that 'good' commits reproduce success" )
241241 else :
@@ -245,6 +245,8 @@ def commit_search(
245245 package : commit_list [0 ][0 ] for package , commit_list in commits .items ()
246246 }
247247 check_pass = build_and_test (commits = passing_commits )
248+ assert _cache_key (passing_commits ) not in result_cache
249+ result_cache [_cache_key (passing_commits )] = check_pass
248250 if check_pass .result :
249251 logger .info ("Verified test passes using 'good' commits" )
250252 else :
@@ -264,6 +266,8 @@ def commit_search(
264266 # below is actionable without checking the debug logfile.
265267 with console_log_level (logger , logging .DEBUG ):
266268 check_fail = build_and_test (commits = failing_commits )
269+ assert _cache_key (failing_commits ) not in result_cache
270+ result_cache [_cache_key (failing_commits )] = check_fail
267271 if not check_fail .result :
268272 logger .info (
269273 "Verified test failure using 'bad' commits. IMPORTANT: you should check "
@@ -303,9 +307,11 @@ def commit_search(
303307 bisect_commits [secondary ] = commit
304308 log_msg += f", { len (commit_list )} remaining { secondary } commits"
305309 logger .info (log_msg )
306- bisect_result = build_and_test (commits = bisect_commits ).result
310+ bisect_result = build_and_test (commits = bisect_commits )
311+ assert _cache_key (bisect_commits ) not in result_cache
312+ result_cache [_cache_key (bisect_commits )] = bisect_result
307313
308- if bisect_result :
314+ if bisect_result . result :
309315 # Test passed, continue searching in the second half
310316 for package , index in indices .items ():
311317 commits [package ] = commits [package ][index :]
@@ -336,7 +342,11 @@ def commit_search(
336342 f"Two { primary } commits remain, checking if { commits [primary ][- 1 ][0 ]} is the "
337343 "culprit"
338344 )
339- blame = build_and_test (commits = blame_commits )
345+ # It's possible that this combination has already been tested at this point
346+ blame = result_cache .get (_cache_key (blame_commits ))
347+ if blame is None :
348+ blame = build_and_test (commits = blame_commits )
349+ result_cache [_cache_key (blame_commits )] = blame
340350 if blame .result :
341351 # Test passed with {pX, sZ, tZ, ...} but was known to fail with
342352 # {pZ, sZ, tZ, ...}. Therefore pZ is the culprit commit.
@@ -349,18 +359,78 @@ def commit_search(
349359 f"{ primary } _bad" : bad_commit ,
350360 f"{ primary } _good" : good_commit ,
351361 }
362+ first_known_bad = {primary : bad_commit }
352363 for secondary , secondary_commit in _not_first (blame_commits ):
364+ first_known_bad [secondary ] = secondary_commit
353365 ret [f"{ secondary } _ref" ] = secondary_commit
354- return ret
366+ # `blame` represents the last-known-good test result, first-known-bad was seen
367+ # earlier, or possibly not at all e.g. if `skip_precondition_checks` is True
368+ # and first-known-bad was the end of the search range.
369+ first_known_bad_result = result_cache .get (_cache_key (first_known_bad ))
370+ if first_known_bad_result is None :
371+ if skip_precondition_checks :
372+ logger .info (
373+ "Did not find a cached result for the first-known-bad "
374+ f"configuration { first_known_bad } , this is probably due to "
375+ "--skip-precondition-checks having been passed."
376+ )
377+ else :
378+ logger .error (
379+ "Did not find a cached result for the first-known-bad "
380+ f"configuration { first_known_bad } , this is unexpected!"
381+ )
382+ return ret , blame , first_known_bad_result
355383 else :
356384 # Test failed with both {pX, sZ, tZ, ...} and {pZ, sZ, tZ, ...}, so
357385 # we can fix the primary package to pX and recurse with the old
358386 # secondary package (s) as the new primary, and the old primary
359387 # package (p) moved to the end.
360388 commits [primary ] = [commits .pop (primary )[0 ]]
361- return commit_search (
389+ return _commit_search (
362390 build_and_test = build_and_test ,
363391 commits = commits ,
364392 logger = logger ,
365393 skip_precondition_checks = True ,
394+ result_cache = result_cache ,
366395 )
396+
397+
398+ def commit_search (
399+ * ,
400+ commits : typing .OrderedDict [
401+ str , typing .Sequence [typing .Tuple [str , datetime .datetime ]]
402+ ],
403+ build_and_test : BuildAndTest ,
404+ logger : logging .Logger ,
405+ skip_precondition_checks : bool ,
406+ ) -> typing .Tuple [
407+ typing .Dict [str , str ],
408+ TestResult ,
409+ typing .Optional [TestResult ],
410+ ]:
411+ """
412+ Bisect a failure back to a single commit.
413+
414+ Arguments:
415+ commits: *ordered* dictionary of commit sequences for different software
416+ packages, e.g. commits["jax"][0] is (hash, date) of the passing JAX
417+ commit. The ordering of packages has implications for precisely how
418+ the triage proceeds.
419+ build_and_test: callable that tests if a given vector of commits passes
420+ logger: instance to log output to
421+ skip_precondition_checks: if True, some tests that should pass/fail by
422+ construction are skipped
423+
424+ Returns a 3-tuple of (summary_dict, last_known_good, first_known_bad),
425+ where the last element can be None if skip_precondition_checks=True. The
426+ last two elements' .result fields will always be, respectively, True and
427+ False, but the other fields can be used to obtain stdout+stderr and
428+ output files from those test invocations.
429+ """
430+ return _commit_search (
431+ commits = commits ,
432+ build_and_test = build_and_test ,
433+ logger = logger ,
434+ skip_precondition_checks = skip_precondition_checks ,
435+ result_cache = {},
436+ )
0 commit comments