| # Copyright 2015 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import json |
| import re |
| import time |
| import urllib |
| |
| from . import bisect_exceptions |
| from . import config_validation |
| from . import depot_config |
| from . import revision_state |
| |
| _DEPS_SHA_PATCH = """ |
| diff --git DEPS.sha DEPS.sha |
| new file mode 100644 |
| --- /dev/null |
| +++ DEPS.sha |
| @@ -0,0 +1 @@ |
| +%(deps_sha)s |
| """ |
| |
| |
| ZERO_TO_NON_ZERO = 'Zero to non-zero' |
| VALID_RESULT_CODES = ( |
| 'TEST_TIMEOUT', # Timed out waiting for the test job. |
| 'BUILD_TIMEOUT', # Timed out waiting for the build. |
| 'TEST_FAILURE', # The test failed to produce parseable results|chartjson. |
| 'BUILD_FAILURE', # The build could not be requested, or the job failed. |
| 'BAD_REV', # The revision range could not be expanded, or the commit |
| # positions could not be resolved into commit hashes. |
| 'REF_RANGE_FAIL', # Either of the initial 'good' or 'bad' revisions failed |
| # to be tested or built. Used with *_FAILURE|*_TIMEOUT. |
| 'BAD_CONFIG', # There was a problem with the bisect_config dictionary |
| # passed to the recipe. See output of the config step |
| 'CULPRIT_FOUND', # A Culprit CL was found with 'high' confidence. |
| 'LO_INIT_CONF', # Bisect aborted early for lack of confidence. |
| 'MISSING_METRIC', # The metric was not found in the test text/json output. |
| 'LO_FINAL_CONF', # The bisect completed without a culprit. |
| ) |
| |
| # When we look for the next revision to build, we search nearby revisions |
| # looking for a revision that's already been archived. Since we don't want |
| # to move *too* far from the original revision, we'll cap the search at 25%. |
| DEFAULT_SEARCH_RANGE_PERCENTAGE = 0.25 |
| |
| _FAILED_INITIAL_CONFIDENCE_ABORT_REASON = ( |
| 'The metric values for the initial "good" and "bad" revisions ' |
| 'do not represent a clear regression.') |
| |
| _DIRECTION_OF_IMPROVEMENT_ABORT_REASON = ( |
| 'The metric values for the initial "good" and "bad" revisions match the ' |
| 'expected direction of improvement. Thus, likely represent an improvement ' |
| 'and not a regression.') |
| |
| |
| class Bisector(object): |
| """This class abstracts an ongoing bisect (or n-sect) job.""" |
| |
| def __init__(self, api, bisect_config, revision_class, init_revisions=True, |
| **flags): |
| """Initializes the state of a new bisect job from a dictionary. |
| |
| Note that the initial good_rev and bad_rev MUST resolve to a commit position |
| in the chromium repo. |
| """ |
| super(Bisector, self).__init__() |
| self.flags = flags |
| self._api = api |
| self.result_codes = set() |
| self.ensure_sync_master_branch() |
| self.bisect_config = bisect_config |
| self.config_step() |
| self._validate_config() |
| self.revision_class = revision_class |
| self.last_tested_revision = None |
| |
| # Test-only properties. |
| # TODO: Replace these with proper mod_test_data. |
| self.dummy_builds = bisect_config.get('dummy_builds', False) |
| self.dummy_tests = bisect_config.get('dummy_tests', False) |
| self.bypass_stats_check = bool(bisect_config.get('bypass_stats_check')) |
| |
| # Load configuration items. |
| self.test_type = bisect_config.get('test_type', 'perf') |
| self.improvement_direction = int(bisect_config.get( |
| 'improvement_direction', 0)) or None |
| |
| self.warnings = [] |
| self.aborted_reason = None |
| |
| # Status flags |
| self.failed_initial_confidence = False |
| self.failed = False |
| self.failed_direction = False |
| self.lkgr = None # Last known good revision |
| self.fkbr = None # First known bad revision |
| self.culprit = None |
| self.bisect_over = False |
| self.relative_change = None |
| self.internal_bisect = api.internal_bisect |
| self.base_depot = 'chromium' |
| if self.internal_bisect: |
| self.base_depot = 'android-chrome' # pragma: no cover |
| |
| # Initial revision range |
| with api.m.step.nest('Expanding reference range'): |
| expanding_step = api.m.step.active_result |
| |
| bad_hash = self._get_hash(bisect_config['bad_revision']) |
| good_hash = self._get_hash(bisect_config['good_revision']) |
| |
| self.revisions = [] |
| self.bad_rev = revision_class(self, bad_hash) |
| self.bad_rev.bad = True |
| self.bad_rev.read_deps(self.get_perf_tester_name()) |
| expanding_step.presentation.logs['DEPS - Bad'] = [ |
| '%s: %s' % (key, value) for key, value in |
| sorted(self.bad_rev.deps.items())] |
| self.bad_rev.deps = {} |
| self.fkbr = self.bad_rev |
| self.good_rev = revision_class(self, good_hash) |
| self.good_rev.good = True |
| self.good_rev.read_deps(self.get_perf_tester_name()) |
| expanding_step.presentation.logs['DEPS - Good'] = [ |
| '%s: %s' % (key, value) for key, value in |
| sorted(self.good_rev.deps.items())] |
| self.good_rev.deps = {} |
| self.lkgr = self.good_rev |
| |
| if init_revisions: |
| self._expand_initial_revision_range(expanding_step.presentation) |
| |
| def _get_hash(self, rev): |
| """Returns a commit hash given either a commit hash or commit position. |
| |
| Args: |
| rev (str): A commit hash or commit position number. |
| |
| Returns: |
| A 40-digit git commit hash string. |
| """ |
| if self._is_sha1(rev): # pragma: no cover |
| return rev |
| if rev.isdigit(): |
| commit_position = self._api.m.commit_position.construct( |
| branch='refs/heads/master', value=rev) |
| try: |
| return self.api.m.crrev.to_commit_hash( |
| commit_position, |
| step_test_data=lambda: self.api._test_data['hash_cp_map'][rev]) |
| except self.api.m.step.StepFailure: # pragma: no cover |
| self.surface_result('BAD_REV') |
| raise |
| self.surface_result('BAD_REV') # pragma: no cover |
| raise self.api.m.step.StepFailure( |
| 'Invalid input revision: %r' % (rev,)) # pragma: no cover |
| |
| @staticmethod |
| def _is_sha1(s): |
| return bool(re.match('^[0-9A-Fa-f]{40}$', s)) |
| |
| def compare_revisions(self, revision_a, revision_b): |
| """ |
| Returns: |
| SIGNIFICANTLY_DIFFERENT if the samples are significantly different. |
| NEED_MORE_DATA if there is not enough data to tell. |
| NOT_SIGNIFICANTLY_DIFFERENT if if there's enough data but still can't |
| tell the samples apart. |
| """ |
| output_format = 'chartjson' |
| values_a = revision_a.chartjson_paths |
| values_b = revision_b.chartjson_paths |
| if revision_a.valueset_paths and revision_b.valueset_paths: |
| output_format = 'valueset' |
| values_a = revision_a.valueset_paths |
| values_b = revision_b.valueset_paths |
| |
| if revision_a.buildbot_paths and revision_b.buildbot_paths: |
| output_format = 'buildbot' |
| values_a = revision_a.buildbot_paths |
| values_b = revision_b.buildbot_paths |
| |
| result = self.api.stat_compare( |
| values_a, |
| values_b, |
| self.bisect_config['metric'], |
| output_format=output_format, |
| step_test_data=lambda: self.api.test_api.compare_samples_data( |
| self.api._test_data['parsed_values'], revision_a, revision_b)) |
| |
| revision_a.debug_values = result['sampleA'] |
| revision_b.debug_values = result['sampleB'] |
| |
| res = result['result']['significance'] |
| if res == revision_state.NEED_MORE_DATA: |
| return revision_state.NEED_MORE_DATA |
| elif res == revision_state.REJECT: |
| return revision_state.SIGNIFICANTLY_DIFFERENT |
| elif res == revision_state.FAIL_TO_REJECT: # pragma: no cover |
| return revision_state.NOT_SIGNIFICANTLY_DIFFERENT |
| else: # pragma: no cover |
| assert False, 'Unexpected result code from compare_samples: ' + res |
| |
| def config_step(self): |
| """Yields a step that prints the bisect config.""" |
| api = self.api |
| |
| # bisect_config may come as a FrozenDict (which is not serializable). |
| bisect_config = dict(self.bisect_config) |
| |
| def fix_windows_backslashes(s): |
| backslash_regex = re.compile(r'(?<!\\)\\(?!\\)') |
| return backslash_regex.sub(r'\\', s) |
| |
| for k, v in bisect_config.iteritems(): |
| if isinstance(v, basestring): |
| bisect_config[k] = fix_windows_backslashes(v) |
| |
| # We sort the keys to prevent problems with orders changing when |
| # recipe_simulation_test compares against expectation files. |
| config_string = json.dumps(bisect_config, indent=2, sort_keys=True) |
| step = api.m.step('config', []) |
| config_lines = config_string.splitlines() |
| step.presentation.logs['Bisect job configuration'] = config_lines |
| |
| def _validate_config(self): |
| """Raises an error and halts the bisect job if the config is invalid.""" |
| try: |
| config_validation.validate_bisect_config(self.bisect_config) |
| except config_validation.ValidationFail as error: |
| self.surface_result('BAD_CONFIG') |
| self.api.m.halt(error.message) |
| raise self.api.m.step.StepFailure(error.message) |
| |
| @property |
| def api(self): |
| return self._api |
| |
| def compute_relative_change(self): |
| old_value = float(self.good_rev.mean or 0) |
| new_value = float(self.bad_rev.mean or 0) |
| |
| if new_value and not old_value: # pragma: no cover |
| self.relative_change = ZERO_TO_NON_ZERO |
| return |
| |
| rel_change = self.api.m.math_utils.relative_change(old_value, new_value) |
| self.relative_change = '%.2f%%' % (100 * rel_change) |
| |
| def _expand_initial_revision_range(self, presentation): |
| """Sets the initial contents of |self.revisions|.""" |
| good_hash = self.good_rev.commit_hash |
| bad_hash = self.bad_rev.commit_hash |
| depot = self.good_rev.depot_name |
| step_name = 'for revisions %s:%s' % (good_hash, bad_hash) |
| revisions = self._revision_range( |
| start=good_hash, |
| end=bad_hash, |
| depot_name=self.base_depot, |
| step_name=step_name, |
| exclude_end=True, |
| step_test_data=lambda: self.api._test_data['revision_list'][depot] |
| ) |
| self.revisions = [self.good_rev] + revisions + [self.bad_rev] |
| self._update_revision_list_indexes() |
| |
| presentation.step_text += ( |
| self.api.m.test_utils.format_step_text( |
| [['Range: %s:%s (%d commits)' % ( |
| good_hash, bad_hash, len(revisions))]])) |
| |
| |
| def _revision_range(self, start, end, depot_name, base_revision=None, |
| step_name=None, exclude_end=False, **kwargs): |
| """Returns a list of RevisionState objects between |start| and |end|. |
| |
| When expanding the initial revision range we want to exclude the last |
| revision, since both good and bad have already been created and tested. |
| When bisecting into a roll on the other hand, we want to include the last |
| revision in the roll, because although the code should be equivalent to |
| the roll, we want to blame the right culprit and not the roll. |
| |
| Args: |
| start (str): Start commit hash. |
| end (str): End commit hash. |
| depot_name (str): Short string name of repo, e.g. chromium or v8. |
| base_revision (str): Base revision in the downstream repo (e.g. chromium). |
| step_name (str): Optional step name. |
| exclude_end (bool): Whether to exclude the last revision in the range, |
| i.e. the revision given as end. The use case for this parameter is |
| when expanding the initial regression range. The "bad" revision has |
| already been instantianted and including it in this response would |
| duplicate it. |
| |
| Returns: |
| A list of RevisionState objects. |
| """ |
| if self.internal_bisect: # pragma: no cover |
| return self._revision_range_with_gitiles( |
| start, end, depot_name, base_revision, step_name, |
| exclude_end=exclude_end, step_test_data=lambda: self.api._test_data[ |
| 'revision_list_internal'][depot_name]) |
| try: |
| step_result = self.api.m.python( |
| step_name, |
| self.api.resource('fetch_intervening_revisions.py'), |
| [start, end, depot_config.DEPOT_DEPS_NAME[depot_name]['url']], |
| stdout=self.api.m.json.output(), **kwargs) |
| except self.api.m.step.StepFailure: # pragma: no cover |
| self.surface_result('BAD_REV') |
| raise |
| revisions = [] |
| revision_hashes = step_result.stdout |
| if exclude_end: |
| revision_hashes = revision_hashes[:-1] |
| for commit_hash, _ in revision_hashes: |
| revisions.append(self.revision_class( |
| bisector=self, |
| commit_hash=commit_hash, |
| depot_name=depot_name, |
| base_revision=base_revision)) |
| return revisions |
| |
| def _revision_range_with_gitiles( |
| self, start, end, depot_name, base_revision=None, step_name=None, |
| exclude_end=False, **kwargs): # pragma: no cover |
| """Returns a list of RevisionState objects between |start| and |end|. |
| |
| Args: |
| start (str): Start commit hash. |
| end (str): End commit hash. |
| depot_name (str): Short string name of repo, e.g. chromium or v8. |
| base_revision (str): Base revision in the downstream repo (e.g. chromium). |
| step_name (str): Optional step name. |
| exclude_end (bool): Whether to exclude the last revision in the range, |
| i.e. the revision given as end. |
| |
| Returns: |
| A list of RevisionState objects, not including the given start or end. |
| """ |
| try: |
| url = depot_config.DEPOT_DEPS_NAME[depot_name]['url'] |
| commits = self._commit_log(start, end, url, step_name, **kwargs) |
| |
| except self.api.m.step.StepFailure: # pragma: no cover |
| self.surface_result('BAD_REV') |
| raise |
| revisions = [] |
| if exclude_end: |
| commits = commits[:-1] |
| for c in commits: |
| revisions.append(self.revision_class( |
| bisector=self, |
| commit_hash=c['commit'], |
| depot_name=depot_name, |
| base_revision=base_revision)) |
| return revisions |
| |
| def _commit_log(self, start, end, url, step_name=None, |
| **kwargs): # pragma: no cover |
| """Fetches information about a range of commits. |
| |
| Args: |
| start (str): The starting commit hash. |
| end (str): The ending commit hash. |
| url (str): The URL of a repository, e.g. |
| "https://chromium.googlesource.com/chromium/src". |
| step_name (str): Optional step name. |
| |
| Returns: |
| A list of dicts for commits in chronological order, including the |
| end commit, but not including the start. Each dict will contain |
| a commit hash (key: "commit") and a commit message (key: "message"). |
| |
| Raises: |
| StepFailure: Failed to fetch the commit log. |
| """ |
| try: |
| ref = '%s..%s' % (start, end) |
| step_name = step_name or 'gitiles log: %s' % ref |
| commits, cursor = self.api.m.gitiles.log( |
| url, ref, limit=2048, step_name=step_name, **kwargs) |
| if cursor: # pragma: no cover |
| raise self.api.m.step.StepFailure('Revision range too large') |
| return list(reversed(commits)) |
| except self.api.m.step.StepFailure: # pragma: no cover |
| self.surface_result('BAD_REV') |
| raise |
| |
| def _expand_deps_revisions(self, revision_to_expand): |
| """Populates the revisions attribute with additional deps revisions. |
| |
| Inserts the revisions from the external repos in the appropriate place. |
| |
| Args: |
| revision_to_expand: A revision where there is a deps change. |
| |
| Returns: |
| A boolean indicating whether any revisions were inserted. |
| """ |
| # TODO(robertocn): Review variable names in this function. They are |
| # potentially confusing. |
| assert revision_to_expand is not None |
| try: |
| min_revision = revision_to_expand.previous_revision |
| max_revision = revision_to_expand |
| # Parses DEPS file and sets the .deps property. |
| min_revision.read_deps(self.get_perf_tester_name()) |
| max_revision.read_deps(self.get_perf_tester_name()) |
| for depot_name in depot_config.DEPOT_DEPS_NAME.keys(): |
| if depot_name in min_revision.deps and depot_name in max_revision.deps: |
| dep_revision_min = min_revision.deps[depot_name] |
| dep_revision_max = max_revision.deps[depot_name] |
| if (dep_revision_min and dep_revision_max and |
| dep_revision_min != dep_revision_max): |
| step_name = ('Expanding revision range for revision %s' |
| ' on depot %s' % (dep_revision_max, depot_name)) |
| rev_list = self._revision_range( |
| start=dep_revision_min, |
| end=dep_revision_max, |
| depot_name=depot_name, |
| base_revision=min_revision, |
| step_name=step_name, |
| step_test_data=lambda: |
| self.api._test_data['revision_list'][depot_name]) |
| new_revisions = self.revisions[:max_revision.list_index] |
| new_revisions += rev_list |
| new_revisions += self.revisions[max_revision.list_index:] |
| self.revisions = new_revisions |
| self._update_revision_list_indexes() |
| return True |
| except RuntimeError: # pragma: no cover |
| warning_text = ('Could not expand dependency revisions for ' + |
| revision_to_expand.commit_hash) |
| self.surface_result('BAD_REV') |
| if warning_text not in self.warnings: |
| self.warnings.append(warning_text) |
| return False |
| |
| def _update_revision_list_indexes(self): |
| """Sets list_index, next and previous properties for each revision.""" |
| for i, rev in enumerate(self.revisions): |
| rev.list_index = i |
| for i in xrange(len(self.revisions)): |
| if i: |
| self.revisions[i].previous_revision = self.revisions[i - 1] |
| if i < len(self.revisions) - 1: |
| self.revisions[i].next_revision = self.revisions[i + 1] |
| |
| def check_improvement_direction(self): # pragma: no cover |
| """Verifies that the change from 'good' to 'bad' is in the right direction. |
| |
| The change between the test results obtained for the given 'good' and |
| 'bad' revisions is expected to be considered a regression. The |
| `improvement_direction` attribute is positive if a larger number is |
| considered better, and negative if a smaller number is considered better. |
| |
| Returns: |
| True if the check passes (i.e. no problem), False if the change is not |
| a regression according to the improvement direction. |
| """ |
| good = self.good_rev.mean |
| bad = self.bad_rev.mean |
| |
| if self.is_return_code_mode(): |
| return True |
| |
| direction = self.improvement_direction |
| if direction is None: |
| return True |
| if (bad > good and direction > 0) or (bad < good and direction < 0): |
| self._set_failed_direction_results() |
| return False |
| return True |
| |
| def _set_failed_return_code_direction_results(self): # pragma: no cover |
| self.failed_direction = True |
| self.warnings.append('The initial regression range for return code ' |
| 'appears to show NO sign of a regression.') |
| |
| def _set_failed_direction_results(self): # pragma: no cover |
| self.failed_direction = True |
| self.warnings.append('The initial regression range appears to represent ' |
| 'an improvement rather than a regression, given the ' |
| 'expected direction of improvement.') |
| |
| def check_initial_confidence(self): # pragma: no cover |
| """Checks that the initial range presents a clear enough regression. |
| |
| We ensure that the good and bad revisions produce significantly different |
| results, increasing the sample size until the compare_revisions function |
| concludes that a difference exists or not, OR the maximum allowed number of |
| repeated tests has been reached. |
| |
| Returns: True if the revisions produced results that differ from each |
| other in a statistically significant manner. Raises an exception otherwise. |
| """ |
| if self.is_return_code_mode(): |
| self.compute_relative_change() |
| return (self.good_rev.overall_return_code != |
| self.bad_rev.overall_return_code) |
| |
| if self.bypass_stats_check: |
| self.compare_revisions(self.good_rev, self.bad_rev) |
| self.compute_relative_change() |
| dummy_result = self.good_rev.mean != self.bad_rev.mean |
| if not dummy_result: |
| self._raise_low_confidence_error() |
| return dummy_result |
| |
| compare_result = self.compare_revisions(self.good_rev, self.bad_rev) |
| |
| # Only create a superstep if retesting will be needed. |
| if compare_result == revision_state.NEED_MORE_DATA: |
| with self.api.m.step.nest('Re-testing reference range'): |
| while (compare_result == revision_state.NEED_MORE_DATA): |
| revision_to_retest = min(self.good_rev, self.bad_rev, |
| key=lambda x: x.test_run_count) |
| if (revision_to_retest.test_run_count |
| >= revision_state.MAX_TESTS_PER_REVISION): |
| break |
| with self.api.m.step.nest('Retesting %s revision' % ( |
| 'GOOD' if revision_to_retest.good else 'BAD')): |
| revision_to_retest._do_test() |
| compare_result = self.compare_revisions(self.good_rev, self.bad_rev) |
| self.compute_relative_change() |
| if compare_result == revision_state.SIGNIFICANTLY_DIFFERENT: |
| return True |
| self._raise_low_confidence_error() |
| |
| def _raise_low_confidence_error(self): |
| if (not self.good_rev.debug_values or |
| not self.bad_rev.debug_values): # pragma: no cover |
| msg = 'No values were found while testing the reference range.' |
| self.surface_result('MISSING_METRIC') |
| else: |
| msg = 'Bisect failed to reproduce the regression with enough confidence.' |
| self.surface_result('LO_INIT_CONF') |
| self.surface_result('REF_RANGE_FAIL') |
| self.failed = True |
| self.failed_initial_confidence = True |
| raise bisect_exceptions.InconclusiveBisectException(msg) |
| |
| def get_exception(self): |
| raise NotImplementedError() # pragma: no cover |
| # TODO: should return an exception with the details of the failure. |
| |
| def _results_debug_message(self): |
| """Returns a string with values used to debug a bisect result.""" |
| result = 'bisector.lkgr: %r\n' % self.lkgr |
| result += 'bisector.fkbr: %r\n\n' % self.fkbr |
| result += self._revision_value_table() |
| if (self.lkgr and self.lkgr.test_run_count and self.fkbr and |
| self.fkbr.test_run_count): |
| result += '\n' + '\n'.join([ |
| 'LKGR values: %r' % list(self.lkgr.display_values), |
| 'FKBR values: %r' % list(self.fkbr.display_values), |
| ]) |
| return result |
| |
| def _revision_value_table(self): |
| """Returns a string table showing revisions and their values.""" |
| header = [['Revision', 'Values']] |
| with self._api.m.step.nest('Resolving hashes'): |
| rows = [[r.revision_string(), str(r.display_values)] |
| for r in self.revisions] |
| return self._pretty_table(header + rows) |
| |
| def _pretty_table(self, data): |
| results = [] |
| for row in data: |
| results.append('%-15s' * len(row) % tuple(row)) |
| return '\n'.join(results) |
| |
| def print_result_debug_info(self): |
| """Prints an extra debug info step at the end of the bisect process.""" |
| lines = self._results_debug_message().splitlines() |
| self.api.m.python.succeeding_step('Debug Info', lines, as_log='Debug Info') |
| |
| def post_result(self, halt_on_failure=False): |
| """Posts bisect results to Perf Dashboard.""" |
| self.api.m.perf_dashboard.set_default_config() |
| lines = self.api.m.json.dumps(self.get_result(), indent=2).splitlines() |
| self.api.m.perf_dashboard.post_bisect_results( |
| self.get_result(), halt_on_failure, debug_info=lines) |
| |
| def get_revision_to_eval(self): |
| """Gets the next RevisionState object in the candidate range. |
| |
| Returns: |
| The next Revision object in a list. |
| """ |
| self._update_candidate_range() |
| candidate_range = [revision for revision in |
| self.revisions[self.lkgr.list_index + 1: |
| self.fkbr.list_index] |
| if not revision.failed] |
| if len(candidate_range) == 1: |
| return candidate_range[0] |
| if len(candidate_range) == 0: |
| return None |
| |
| default_revision = candidate_range[len(candidate_range) / 2] |
| |
| with self.api.m.step.nest( |
| 'Wiggling revision ' + default_revision.revision_string()): |
| # We'll search up to 25% of the range (in either direction) to try and |
| # find a nearby commit that's already been built. |
| max_wiggle = int(len(candidate_range) * DEFAULT_SEARCH_RANGE_PERCENTAGE) |
| for _ in xrange(max_wiggle): # pragma: no cover |
| index = len(candidate_range) / 2 |
| if candidate_range[index]._is_build_archived(): |
| return candidate_range[index] |
| del candidate_range[index] |
| |
| return default_revision |
| |
| def check_reach_adjacent_revision(self, revision): |
| """Checks if this revision reaches its adjacent revision. |
| |
| Reaching the adjacent revision means one revision considered 'good' |
| immediately preceding a revision considered 'bad'. |
| """ |
| if (revision.bad and revision.previous_revision and |
| revision.previous_revision.good): |
| return True |
| if (revision.good and revision.next_revision and |
| revision.next_revision.bad): |
| return True |
| return False |
| |
| def check_bisect_finished(self, revision): |
| """Checks if this revision completes the bisection process. |
| |
| In this case 'finished' refers to finding one revision considered 'good' |
| immediately preceding a revision considered 'bad' where the 'bad' revision |
| does not contain a DEPS change. |
| """ |
| if (revision.bad and revision.previous_revision and |
| revision.previous_revision.good): |
| if revision.deps_change() and self._expand_deps_revisions(revision): |
| return False |
| self.culprit = revision |
| return True |
| if (revision.good and revision.next_revision and |
| revision.next_revision.bad): |
| if (revision.next_revision.deps_change() |
| and self._expand_deps_revisions(revision.next_revision)): |
| return False |
| self.culprit = revision.next_revision |
| return True |
| # We'll never get here because revision adjacency is checked before this |
| # function is called. |
| assert False # pragma: no cover |
| |
| def _update_candidate_range(self): |
| """Updates lkgr and fkbr (last known good/first known bad) revisions. |
| |
| lkgr and fkbr are 'pointers' to the appropriate RevisionState objects in |
| bisectors.revisions.""" |
| for r in self.revisions: |
| if r.test_run_count: |
| if r.good: |
| self.lkgr = r |
| elif r.bad: |
| self.fkbr = r |
| break |
| assert self.lkgr and self.fkbr |
| |
| def get_perf_tester_name(self): |
| """Gets the name of the tester bot (on tryserver.chromium.perf) to use. |
| |
| If the tester bot is explicitly specified using "recipe_tester_name" |
| in the bisect config, use that; otherwise make a best guess. |
| """ |
| original_bot_name = self.bisect_config.get('original_bot_name', '') |
| recipe_tester_name = self.bisect_config.get('recipe_tester_name') |
| if recipe_tester_name: |
| return recipe_tester_name |
| elif 'win' in original_bot_name: # pragma: no cover |
| return 'win64_nv_tester' |
| else: # pragma: no cover |
| # Reasonable fallback. |
| return 'linux_perf_tester' |
| |
| def get_builder_bot_for_this_platform(self): |
| """Returns the name of the builder bot to use.""" |
| if self.api.builder_bot: # pragma: no cover |
| return self.api.builder_bot |
| |
| # TODO(prasadv): Refactor this code to remove hard coded values. |
| bot_name = self.get_perf_tester_name() |
| |
| # TODO(prasadv): Refactor this code to remove hard coded values and use |
| # target_bit from the bot config. crbug.com/640287 |
| if 'android' in bot_name: # pragma: no cover |
| if any(b in bot_name for b in ['arm64', 'nexus9']): |
| return 'android_arm64_perf_bisect_builder' |
| return 'android_perf_bisect_builder' |
| |
| return 'linux_perf_bisect_builder' |
| |
| def get_platform_gs_prefix(self): |
| """Returns the prefix of a GS URL where a build can be found. |
| |
| This prefix includes the schema, bucket, directory and beginning |
| of filename. It is joined together with the part of the filename |
| that includes the revision and the file extension to form the |
| full GS URL. |
| """ |
| if self.api.buildurl_gs_prefix: # pragma: no cover |
| return self.api.buildurl_gs_prefix |
| |
| # TODO(prasadv): Refactor this code to remove hard coded values. |
| bot_name = self.get_perf_tester_name() |
| |
| # TODO(prasadv): Refactor this code to remove hard coded values and use |
| # target_bit from the bot config. crbug.com/640287 |
| if 'android' in bot_name: #pragma: no cover |
| if any(b in bot_name for b in ['arm64', 'nexus9']): |
| return 'gs://chrome-perf/Android arm64 Builder/full-build-linux_' |
| return 'gs://chrome-perf/Android Builder/full-build-linux_' |
| |
| return 'gs://chrome-perf/Linux Builder/full-build-linux_' |
| |
| def ensure_sync_master_branch(self): |
| """Make sure the local master is in sync with the fetched origin/master. |
| |
| We have seen on several occasions that the local master branch gets reset |
| to previous revisions and also detached head states. Running this should |
| take care of either situation. |
| """ |
| # TODO(robertocn): Investigate what causes the states mentioned in the |
| # docstring in the first place. |
| with self.api.m.context(cwd=self.api.m.path['checkout']): |
| self.api.m.git('update-ref', 'refs/heads/master', |
| 'refs/remotes/origin/master') |
| self.api.m.git('checkout', 'master') |
| |
| def is_return_code_mode(self): |
| """Checks whether this is a bisect on the test's exit code.""" |
| return self.test_type == 'return_code' |
| |
| def surface_result(self, result_string): |
| assert result_string in VALID_RESULT_CODES |
| prefix = 'B4T_' # To avoid collision. Stands for bisect (abbr. `a la i18n). |
| result_code = prefix + result_string |
| assert len(result_code) <= 20 |
| if result_code not in self.result_codes: |
| self.result_codes.add(result_code) |
| properties = self.api.m.step.active_result.presentation.properties |
| properties['extra_result_code'] = sorted(self.result_codes) |
| |
| def get_result(self): |
| """Returns the results as a jsonable object.""" |
| config = self.bisect_config |
| |
| if self.failed: |
| status = 'failed' |
| elif self.bisect_over: |
| status = 'completed' |
| else: |
| status = 'started' |
| |
| aborted_reason = None |
| if self.failed_initial_confidence: |
| aborted_reason = _FAILED_INITIAL_CONFIDENCE_ABORT_REASON |
| elif self.failed_direction: # pragma: no cover |
| aborted_reason = _DIRECTION_OF_IMPROVEMENT_ABORT_REASON |
| |
| return { |
| 'try_job_id': config.get('try_job_id'), |
| 'bug_id': config.get('bug_id'), |
| 'status': status, |
| 'buildbot_log_url': self._get_build_url(), |
| 'bisect_bot': self.get_perf_tester_name(), |
| 'command': config['command'], |
| 'test_type': config['test_type'], |
| 'metric': config.get('metric'), |
| 'change': self.relative_change, |
| 'good_revision': self.good_rev.commit_hash, |
| 'bad_revision': self.bad_rev.commit_hash, |
| 'warnings': self.warnings, |
| 'aborted_reason': self.aborted_reason or aborted_reason, |
| 'culprit_data': self._culprit_data(), |
| 'revision_data': self._revision_data(), |
| } |
| |
| def _culprit_data(self): |
| culprit = self.culprit |
| api = self.api |
| if not culprit: |
| return None |
| culprit_info = api.query_revision_info(self.culprit) |
| |
| if not culprit_info: # pragma: no cover |
| return None |
| |
| return { |
| 'subject': culprit_info['subject'], |
| 'author': culprit_info['author'], |
| 'email': culprit_info['email'], |
| 'cl_date': culprit_info['date'], |
| 'commit_info': culprit_info['body'], |
| 'revisions_links': [], |
| 'cl': culprit.commit_hash |
| } |
| |
| def _revision_data(self): |
| revision_rows = [] |
| for r in self.revisions: |
| row = { |
| 'depot_name': r.depot_name, |
| 'commit_hash': r.commit_hash, |
| 'revision_string': r.revision_string(), |
| 'n_observations': len(r.display_values), |
| 'result': 'good' if r.good else 'bad' if r.bad else 'unknown', |
| 'failed': r.failed, |
| 'failure_reason': r.failure_reason, |
| 'build_id': r.build_id, |
| } |
| if r.test_run_count: |
| row['mean_value'] = (r.overall_return_code |
| if r.bisector.is_return_code_mode() else r.mean) |
| row['std_dev'] = r.std_dev |
| revision_rows.append(row) |
| return revision_rows |
| |
| def _get_build_url(self): |
| properties = self.api.m.properties |
| bot_url = properties.get('buildbotURL', |
| 'http://build.chromium.org/p/chromium/') |
| builder_name = urllib.quote(properties.get('buildername', '')) |
| builder_number = str(properties.get('buildnumber', '')) |
| return '%sbuilders/%s/builds/%s' % (bot_url, builder_name, builder_number) |