Skip to content

Commit 02946a2

Browse files
committed
Add fallback-mode option for subset
backport v2 merged code from #1308
1 parent 010d18b commit 02946a2

2 files changed

Lines changed: 162 additions & 6 deletions

File tree

launchable/commands/subset.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
import json
33
import os
44
import pathlib
5+
import random
56
import re
67
import subprocess
78
import sys
9+
from enum import Enum
810
from multiprocessing import Process
911
from os.path import join
1012
from typing import Any, Callable, Dict, List, Optional, Sequence, TextIO, Tuple, Union
@@ -27,6 +29,12 @@
2729
from .helper import find_or_create_session
2830
from .test_path_writer import TestPathWriter
2931

32+
class FallbackMode(str, Enum):
33+
RUN_ALL = "run-all"
34+
STOP = "stop"
35+
RANDOM_SAMPLE = "random-sample"
36+
37+
3038
LARGE_TEST_PATHS_THRESHOLD = 100000
3139
DEFAULT_CONNECT_TIMEOUT = 5
3240
LARGE_PAYLOAD_CONNECT_TIMEOUT = 60
@@ -231,6 +239,17 @@
231239
type=str,
232240
hidden=True,
233241
)
242+
@click.option(
243+
"--fallback-mode",
244+
"fallback_mode",
245+
hidden=True,
246+
type=click.Choice(["run-all", "stop", "random-sample"]),
247+
default="run-all",
248+
help="Behavior when the subset API is unavailable or the model is untrained. "
249+
"'run-all' (default) runs all tests as usual; 'stop' exits with a non-zero status so CI halts; "
250+
"'random-sample' picks a random subset locally based on the count derived from --target "
251+
"(no duration estimates are available in this path).",
252+
)
234253
@click.pass_context
235254
def subset(
236255
context: click.core.Context,
@@ -262,7 +281,9 @@ def subset(
262281
use_case: Optional[str] = None,
263282
similarity: Optional[float] = None,
264283
subset_id_file: Optional[str] = None,
284+
fallback_mode: str = "run-all",
265285
):
286+
fallback_mode_enum = FallbackMode(fallback_mode)
266287
app = context.obj
267288
tracking_client = TrackingClient(Command.SUBSET, app=app)
268289
client = LaunchableClient(
@@ -402,6 +423,7 @@ def __init__(self, app: Application):
402423
self.is_output_exclusion_rules = is_output_exclusion_rules
403424
self.is_get_tests_from_guess = is_get_tests_from_guess
404425
self.subset_id_file = subset_id_file
426+
self.fallback_mode = fallback_mode_enum
405427
super(Optimize, self).__init__(app=app)
406428

407429
def _default_output_handler(self, output: List[TestPath], rests: List[TestPath]):
@@ -587,6 +609,23 @@ def _write_subset_id_to_file(self, subset_result: SubsetResult):
587609
with open(self.subset_id_file, 'w', encoding='utf-8') as f:
588610
f.write(str(subset_result.subset_id) + '\n')
589611

612+
def _fallback_result(self) -> SubsetResult:
613+
if self.fallback_mode == FallbackMode.STOP:
614+
click.echo(
615+
"Warning: the service failed to subset. Stopping build (--fallback-mode=stop).",
616+
err=True,
617+
)
618+
sys.exit(1)
619+
elif self.fallback_mode == FallbackMode.RANDOM_SAMPLE:
620+
target_fraction = float(target) if target is not None else 1.0
621+
click.echo(
622+
"Warning: the service failed to subset. Falling back to local random sample at {:.0%}.".format(target_fraction),
623+
err=True,
624+
)
625+
return SubsetResult.from_random_sample(self.test_paths, target_fraction)
626+
else:
627+
return SubsetResult.from_test_paths(self.test_paths)
628+
590629
def request_subset(self) -> SubsetResult:
591630
test_runner = context.invoked_subcommand
592631
# temporarily extend the timeout because subset API response has become slow
@@ -622,7 +661,7 @@ def request_subset(self) -> SubsetResult:
622661
)
623662
client.print_exception_and_recover(
624663
e, "Warning: the service failed to subset. Falling back to running all tests")
625-
return SubsetResult.from_test_paths(self.test_paths)
664+
return self._fallback_result()
626665

627666
def run(self):
628667
"""called after tests are scanned to compute the optimized order"""
@@ -642,10 +681,16 @@ def run(self):
642681
if not session_id:
643682
# Session ID in --session is missing. It might be caused by
644683
# Launchable API errors.
645-
subset_result = SubsetResult.from_test_paths(self.test_paths)
684+
subset_result = self._fallback_result()
646685
else:
647686
subset_result = self.request_subset()
648687

688+
if subset_result.is_brainless:
689+
click.echo("Your model is currently in training", err=True)
690+
# brainless mode splits tests on server, so skip client-side fallback for random-sample
691+
if self.fallback_mode != FallbackMode.RANDOM_SAMPLE:
692+
subset_result = self._fallback_result()
693+
649694
if len(subset_result.subset) == 0:
650695
if len(subset_result.rest) > 0 and client.is_pts_v2_enabled() and confidence is not None:
651696
# Adaptive Dynamic Subset can return an empty subset when the model
@@ -707,10 +752,6 @@ def run(self):
707752
],
708753
]
709754

710-
if subset_result.is_brainless:
711-
click.echo(
712-
"Your model is currently in training", err=True)
713-
714755
click.echo(
715756
"Launchable created subset {} for build {} (test session {}) in workspace {}/{}".format(
716757
subset_result.subset_id,
@@ -775,3 +816,11 @@ def from_test_paths(cls, test_paths: List[TestPath]) -> 'SubsetResult':
775816
is_brainless=False,
776817
is_observation=False
777818
)
819+
820+
@classmethod
821+
def from_random_sample(cls, test_paths: List[TestPath], target: float) -> 'SubsetResult':
822+
count = max(1, round(len(test_paths) * target))
823+
sampled = random.sample(test_paths, min(count, len(test_paths)))
824+
sampled_set = {id(t): t for t in sampled}
825+
rest = [t for t in test_paths if id(t) not in sampled_set]
826+
return cls(subset=sampled, rest=rest, subset_id='', summary={}, is_brainless=False, is_observation=False)

tests/commands/test_api_error.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,3 +473,110 @@ def assert_tracking_count(self, tracking, count: int):
473473
if attempt > 10:
474474
break
475475
self.assertEqual(tracking.call_count, count)
476+
477+
478+
class FallbackModeTest(CliTestCase):
479+
test_files_dir = Path(__file__).parent.joinpath('../data/minitest/').resolve()
480+
481+
def _subset_args(self, rest_file_name, extra_args=()):
482+
return (
483+
"subset", "--target", "50%",
484+
"--session", self.session,
485+
"--rest", rest_file_name,
486+
) + tuple(extra_args) + (
487+
"minitest",
488+
str(self.test_files_dir) + "/test/**/*.rb",
489+
)
490+
491+
# --- API error cases ---
492+
493+
@responses.activate
494+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
495+
def test_api_error_fallback_stop(self):
496+
responses.replace(
497+
responses.POST,
498+
"{base}/intake/organizations/{org}/workspaces/{ws}/subset".format(
499+
base=get_base_url(), org=self.organization, ws=self.workspace),
500+
status=500)
501+
502+
with tempfile.NamedTemporaryFile(delete=False) as rest_file:
503+
result = self.cli(*self._subset_args(rest_file.name, ("--fallback-mode", "stop")), mix_stderr=False)
504+
self.assertEqual(result.exit_code, 1)
505+
506+
@responses.activate
507+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
508+
def test_api_error_fallback_random_sample(self):
509+
responses.replace(
510+
responses.POST,
511+
"{base}/intake/organizations/{org}/workspaces/{ws}/subset".format(
512+
base=get_base_url(), org=self.organization, ws=self.workspace),
513+
status=500)
514+
515+
with tempfile.NamedTemporaryFile(delete=False) as rest_file:
516+
result = self.cli(*self._subset_args(rest_file.name, ("--fallback-mode", "random-sample")), mix_stderr=False)
517+
self.assert_success(result)
518+
self.assertIn("example_test.rb", result.stdout)
519+
520+
@responses.activate
521+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
522+
def test_api_error_fallback_run_all_default(self):
523+
responses.replace(
524+
responses.POST,
525+
"{base}/intake/organizations/{org}/workspaces/{ws}/subset".format(
526+
base=get_base_url(), org=self.organization, ws=self.workspace),
527+
status=500)
528+
529+
with tempfile.NamedTemporaryFile(delete=False) as rest_file:
530+
result = self.cli(*self._subset_args(rest_file.name), mix_stderr=False)
531+
self.assert_success(result)
532+
self.assertIn("example_test.rb", result.stdout)
533+
534+
# --- Brainless mode cases ---
535+
536+
@responses.activate
537+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
538+
def test_brainless_fallback_stop(self):
539+
responses.replace(
540+
responses.POST,
541+
"{base}/intake/organizations/{org}/workspaces/{ws}/subset".format(
542+
base=get_base_url(), org=self.organization, ws=self.workspace),
543+
json={"testPaths": [[{"type": "file", "name": "example_test.rb"}]],
544+
"rest": [], "subsettingId": 1, "isBrainless": True, "summary": {}},
545+
status=200)
546+
547+
with tempfile.NamedTemporaryFile(delete=False) as rest_file:
548+
result = self.cli(*self._subset_args(rest_file.name, ("--fallback-mode", "stop")), mix_stderr=False)
549+
self.assertEqual(result.exit_code, 1)
550+
551+
@responses.activate
552+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
553+
def test_brainless_fallback_random_sample(self):
554+
# In brainless mode the server already split the tests, so random-sample keeps the server's result as-is.
555+
responses.replace(
556+
responses.POST,
557+
"{base}/intake/organizations/{org}/workspaces/{ws}/subset".format(
558+
base=get_base_url(), org=self.organization, ws=self.workspace),
559+
json={"testPaths": [[{"type": "file", "name": "example_test.rb"}]],
560+
"rest": [], "subsettingId": 1, "isBrainless": True, "summary": {}},
561+
status=200)
562+
563+
with tempfile.NamedTemporaryFile(delete=False) as rest_file:
564+
result = self.cli(*self._subset_args(rest_file.name, ("--fallback-mode", "random-sample")), mix_stderr=False)
565+
self.assert_success(result)
566+
self.assertIn("example_test.rb", result.stdout)
567+
568+
@responses.activate
569+
@mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token})
570+
def test_brainless_fallback_run_all_default(self):
571+
responses.replace(
572+
responses.POST,
573+
"{base}/intake/organizations/{org}/workspaces/{ws}/subset".format(
574+
base=get_base_url(), org=self.organization, ws=self.workspace),
575+
json={"testPaths": [[{"type": "file", "name": "example_test.rb"}]],
576+
"rest": [], "subsettingId": 1, "isBrainless": True, "summary": {}},
577+
status=200)
578+
579+
with tempfile.NamedTemporaryFile(delete=False) as rest_file:
580+
result = self.cli(*self._subset_args(rest_file.name), mix_stderr=False)
581+
self.assert_success(result)
582+
self.assertIn("example_test.rb", result.stdout)

0 commit comments

Comments
 (0)