| # 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. |
| |
| """An interface for holding state and result of revisions in a bisect job. |
| |
| When implementing support for tests other than perf, one should extend this |
| class so that the bisect module and recipe can use it. |
| |
| See perf_revision_state for an example. |
| """ |
| |
| import collections |
| import hashlib |
| import json |
| import math |
| import os |
| import tempfile |
| import re |
| |
| from . import depot_config |
| from . import bisect_exceptions |
| |
| # These relate to how to increase the number of repetitions during re-test |
| MINIMUM_SAMPLE_SIZE = 6 |
| INCREASE_FACTOR = 1.5 |
| # If after testing a revision this many times it cannot be classified, fail. |
| MAX_TESTS_PER_REVISION = 20 |
| |
| NOT_SIGNIFICANTLY_DIFFERENT = 'NOT_SIGNIFICANTLY_DIFFERENT' |
| SIGNIFICANTLY_DIFFERENT = 'SIGNIFICANTLY_DIFFERENT' |
| NEED_MORE_DATA = 'NEED_MORE_DATA' |
| REJECT = 'REJECT' |
| FAIL_TO_REJECT = 'FAIL_TO_REJECT' |
| |
| class RevisionState(object): |
| """Abstracts the state of a single revision on a bisect job.""" |
| |
| def __init__(self, bisector, commit_hash, depot_name=None, |
| base_revision=None): |
| """Creates a new instance to track the state of a revision. |
| |
| Args: |
| bisector (Bisector): The object performing the bisection. |
| commit_hash (str): The hash identifying the revision to represent. |
| depot_name (str): The name of the depot as specified in DEPS. Must be a |
| key in depot_config.DEPOT_DEPS_NAME . |
| base_revision (RevisionState): The revision state to patch with the deps |
| change. |
| """ |
| super(RevisionState, self).__init__() |
| self.bisector = bisector |
| self._good = None |
| self._built = False |
| self._failed_build = None |
| self.failed = False |
| self.deps = None |
| self.test_results_url = None |
| self.build_archived = False |
| self.next_revision = None |
| self.previous_revision = None |
| self.job_name = None |
| self.patch_file = None |
| self.deps_revision = None |
| self.depot_name = depot_name or self.bisector.base_depot |
| self.depot = depot_config.DEPOT_DEPS_NAME[self.depot_name] |
| self.commit_hash = str(commit_hash) |
| self._rev_str = None |
| self.base_revision = base_revision |
| self.top_revision = self |
| # The difference between revsion overrides and revsion ladder is that the |
| # former is indexed by paths, e.g. 'src/third_party/skia' and the latter is |
| # indexed by depot name, e.g. 'skia'. |
| self.revision_overrides = {} |
| self.revision_ladder = {self.depot_name: self.commit_hash} |
| self.build_id = None |
| if self.base_revision: |
| self.needs_patch = True |
| self.revision_overrides[self.depot['src']] = self.commit_hash |
| # To allow multiple nested levels, we go to the topmost revision. |
| while self.top_revision.base_revision: |
| self.top_revision = self.top_revision.base_revision |
| # We want to carry over any overrides from the base revision(s) without |
| # overwriting any overrides specified at the self level. |
| self.revision_ladder[self.top_revision.depot_name] = ( |
| self.top_revision.commit_hash) |
| self.revision_overrides = dict( |
| self.top_revision.revision_overrides.items() + |
| self.revision_overrides.items()) |
| self.deps_sha = hashlib.sha1(self.revision_string()).hexdigest() |
| self.deps = dict(base_revision.deps) |
| self.deps[self.depot_name] = self.commit_hash |
| else: |
| self.needs_patch = False |
| self.build_url = self.bisector.get_platform_gs_prefix() + self._gs_suffix() |
| self.valueset_paths = [] |
| self.chartjson_paths = [] |
| self.buildbot_paths = [] |
| self.debug_values = [] |
| self.return_codes = [] |
| self._test_config = None |
| self.failure_reason = None |
| |
| if self.bisector.test_type == 'perf': |
| self.repeat_count = MINIMUM_SAMPLE_SIZE |
| else: |
| self.repeat_count = self.bisector.bisect_config.get( |
| 'repeat_count', MINIMUM_SAMPLE_SIZE) |
| |
| @property |
| def good(self): |
| return self._good == True |
| |
| @property |
| def bad(self): |
| return self._good == False |
| |
| @good.setter |
| def good(self, value): |
| self._good = value |
| |
| @bad.setter |
| def bad(self, value): |
| self._good = not value |
| |
| @property |
| def test_run_count(self): |
| return max( |
| len(self.valueset_paths), |
| len(self.chartjson_paths), |
| len(self.buildbot_paths), |
| len(self.return_codes)) |
| |
| @property |
| def display_values(self): |
| if self.bisector.is_return_code_mode(): |
| return self.return_codes |
| return self.debug_values |
| |
| @property |
| def mean(self): |
| if self.debug_values: |
| return float(sum(self.debug_values))/len(self.debug_values) |
| |
| @property |
| def std_dev(self): |
| if self.debug_values: |
| mn = self.mean |
| numer = sum(pow(x - mn, 2) for x in self.debug_values) |
| # Sample standard deviation |
| N = float(max(1, len(self.debug_values) - 1)) |
| return math.sqrt(numer / N) |
| |
| def _check_values_produced(self): |
| """Checks if any values were output from tests.""" |
| api = self.bisector.api |
| if api._test_data.enabled: |
| return api._test_data.get('parsed_values', {}).get(self.commit_hash) |
| return ( # pragma: no cover |
| self.chartjson_paths or self.valueset_paths or self.buildbot_paths) |
| |
| def start_job(self): |
| api = self.bisector.api |
| try: |
| if not self._is_build_archived(): |
| self._request_build() |
| with api.m.step.nest('Waiting for build'): |
| while not self._is_build_archived(): |
| api.m.python.inline( |
| 'sleeping', |
| """ |
| import sys |
| import time |
| time.sleep(5*60) |
| sys.exit(0) |
| """) |
| if self.is_build_failed(): |
| self.failed = True |
| self.failure_reason = ( |
| 'Failed to compile revision %s. Buildbucket job id %s' % ( |
| self.revision_string(), self.build_id)) |
| return |
| |
| # Individual tests failing don't need to break the entire bisect run. |
| try: |
| self._do_test() |
| # TODO(robertocn): Add a test to remove this CL |
| while not self._check_revision_good(): # pragma: no cover |
| min(self, self.bisector.lkgr, self.bisector.fkbr, |
| key=lambda(x): x.test_run_count)._do_test() |
| except api.m.step.StepFailure as e: # pragma: no cover |
| raise bisect_exceptions.UntestableRevisionException(e.reason) |
| |
| # If this is the initial good/bad revision, we should to check if any |
| # values are even produced and fail if they aren't. This allows the |
| # "Gathering Reference Values" step to fail instead of some unrelated |
| # future step. |
| if not self.bisector.is_return_code_mode(): |
| if (self in [self.bisector.good_rev, self.bisector.bad_rev] and not |
| self._check_values_produced()): |
| self.failed = True |
| self.failure_reason = 'Test runs failed to produce output.' |
| |
| except bisect_exceptions.UntestableRevisionException as e: |
| self.failure_reason = e.message |
| self.failed = True |
| |
| |
| def deps_change(self): |
| """Uses `git show` to see if a given commit contains a DEPS change.""" |
| api = self.bisector.api |
| working_dir = api.working_dir |
| cwd = working_dir.join( |
| depot_config.DEPOT_DEPS_NAME[self.depot_name]['src']) |
| name = 'Checking DEPS for ' + self.commit_hash |
| with api.m.context(cwd=cwd): |
| step_result = api.m.git( |
| 'show', '--name-only', '--pretty=format:', |
| self.commit_hash, stdout=api.m.raw_io.output_text(), name=name, |
| step_test_data=lambda: api._test_data['deps_change'][self.commit_hash] |
| ) |
| if 'DEPS' in step_result.stdout.splitlines(): # pragma: no cover |
| return True |
| return False # pragma: no cover |
| |
| def _gen_deps_local_scope(self): |
| """Defines the Var and From functions in a dict for calling exec. |
| |
| This is needed for executing the DEPS file. |
| """ |
| deps_data = { |
| 'Var': lambda _: deps_data['vars'][_], |
| 'From': lambda *args: None, |
| } |
| return deps_data |
| |
| def _read_content(self, url, file_name, branch): # pragma: no cover |
| """Uses gitiles recipe module to download and read file contents.""" |
| api = self.bisector.api |
| try: |
| return api.m.gitiles.download_file( |
| repository_url=url, file_path=file_name, branch=branch, |
| step_test_data=lambda: api._test_data['download_deps'].get( |
| self.commit_hash, '')) |
| except TypeError: |
| err = 'Could not read content for %s/%s/%s' % (url, file_name, branch) |
| api.m.step.active_result.presentation.status = api.m.step.WARNING |
| api.m.step.active_result.presentation.logs['Gitiles Warning'] = [err] |
| return None |
| |
| def read_deps(self, recipe_tester_name): |
| """Sets the dependencies for this revision from the contents of DEPS.""" |
| api = self.bisector.api |
| if (self.bisector.internal_bisect and |
| 'deps_file' in self.depot): # pragma: no cover |
| deps_file_contents = self._read_content( |
| self.depot['url'], |
| self.depot['deps_file'], |
| self.commit_hash) |
| # On April 5th, 2016 .DEPS.git was changed to DEPS on android-chrome repo, |
| # we are doing this in order to support both deps files. |
| if not deps_file_contents: |
| deps_file_contents = self._read_content( |
| self.depot['url'], |
| depot_config.DEPS_FILENAME, |
| self.commit_hash) |
| else: |
| step_result = api.m.python( |
| 'fetch file %s:%s' % (self.commit_hash, depot_config.DEPS_FILENAME), |
| api.resource('fetch_file.py'), |
| [depot_config.DEPS_FILENAME, '--commit', self.commit_hash], |
| stdout=api.m.raw_io.output_text(), |
| step_test_data=lambda: api._test_data['deps'][self.commit_hash] |
| ) |
| deps_file_contents = step_result.stdout |
| try: |
| deps_data = self._gen_deps_local_scope() |
| exec (deps_file_contents or 'deps = {}') in {}, deps_data |
| deps_data = deps_data['deps'] |
| except ImportError: # pragma: no cover |
| # TODO(robertocn): Implement manual parsing of DEPS when exec fails. |
| raise NotImplementedError('Path not implemented to manually parse DEPS') |
| |
| revision_regex = re.compile('.git@(?P<revision>[a-fA-F0-9]+)') |
| results = {} |
| for depot_name, depot_data in depot_config.DEPOT_DEPS_NAME.iteritems(): |
| if (depot_data.get('platform') and |
| depot_data.get('platform') not in recipe_tester_name.lower()): |
| continue |
| |
| if (depot_data.get('recurse') and |
| self.depot_name in depot_data.get('from')): |
| depot_data_src = depot_data.get('src') or depot_data.get('src_old') |
| src_dir = deps_data.get(depot_data_src) |
| if src_dir: |
| re_results = revision_regex.search(src_dir) |
| if re_results: |
| results[depot_name] = re_results.group('revision') |
| else: # pragma: no cover |
| warning_text = ('Could not parse revision for %s while bisecting ' |
| '%s' % (depot_name, self.depot)) |
| if warning_text not in self.bisector.warnings: |
| self.bisector.warnings.append(warning_text) |
| else: |
| results[depot_name] = None |
| self.deps = results |
| return |
| |
| def _is_build_archived(self): |
| """Checks if the revision is already built and archived.""" |
| if not self.build_archived: |
| api = self.bisector.api |
| data = [] |
| if api._test_data.enabled: |
| data = (api._test_data.get('gsutil_exists', {}).get(self.commit_hash) or |
| [collections.namedtuple('retcode_attr', ['retcode'])(0)]) |
| self.build_archived = api.gsutil_file_exists( |
| self.build_url, step_test_data=data.pop) |
| self._built = self.build_archived |
| return self.build_archived |
| |
| def is_build_failed(self): |
| if self._built: # pragma: no cover |
| return self._failed_build |
| api = self.bisector.api |
| api.m.buildbucket.use_service_account_key( |
| api.m.puppet_service_account.get_key_path(api.SERVICE_ACCOUNT)) |
| try: |
| result = api.m.buildbucket.get_build( |
| self.build_id, |
| step_test_data=lambda: api.test_api.buildbot_job_status_mock( |
| api._test_data.get('build_status', {}).get(self.commit_hash, []))) |
| except api.m.step.StepFailure: |
| # If the check fails, we cannot assume that the build is failed. |
| return False |
| if result.stdout['build']['status'] == 'COMPLETED': |
| self._built = True |
| if result.stdout['build'].get('result') != 'SUCCESS': |
| self._failed_build = True |
| return True |
| return False |
| |
| def _gs_suffix(self): |
| """Provides the expected right half of the build filename. |
| |
| This takes into account whether the build has a deps patch. |
| """ |
| name_parts = [self.top_revision.commit_hash] |
| if self.needs_patch: |
| name_parts.append(self.deps_sha) |
| return '%s.zip' % '_'.join(name_parts) |
| |
| def _read_test_results(self, results): |
| # Results will be a dictionary containing path to chartjsons, paths to |
| # valueset, list of return codes. |
| self.return_codes.extend(results.get('retcodes', [])) |
| if results.get('errors'): # pragma: no cover |
| self.failed = True |
| if 'MISSING_METRIC' in results.get('errors'): |
| self.bisector.surface_result('MISSING_METRIC') |
| raise bisect_exceptions.UntestableRevisionException( |
| 'The metric was not found in the output.') |
| elif self.bisector.is_return_code_mode(): |
| assert len(results['retcodes']) |
| else: |
| self.valueset_paths.extend(results.get('valueset_paths')) |
| self.chartjson_paths.extend(results.get('chartjson_paths')) |
| self.buildbot_paths.extend(results.get('stdout_paths')) |
| |
| def _request_build(self): |
| """Posts a request to buildbot to build this revision and archive it.""" |
| api = self.bisector.api |
| bot_name = self.bisector.get_builder_bot_for_this_platform() |
| build_details = { |
| 'bucket': 'master.' + api.m.properties['mastername'], |
| 'parameters': { |
| 'builder_name': bot_name, |
| 'properties': { |
| 'parent_got_revision': self.top_revision.commit_hash, |
| 'clobber': True, |
| 'build_archive_url': self.build_url, |
| }, |
| }, |
| } |
| if self.revision_overrides: |
| build_details['parameters']['properties']['deps_revision_overrides'] = \ |
| self.revision_overrides |
| |
| api.m.buildbucket.use_service_account_key( |
| api.m.puppet_service_account.get_key_path(api.SERVICE_ACCOUNT)) |
| try: |
| result = api.m.buildbucket.put( |
| [build_details], |
| step_test_data=lambda: api.m.json.test_api.output_stream( |
| {'results':[{'build':{'id':'1201331270'}}]})) |
| except api.m.step.StepFailure: # pragma: no cover |
| self.bisector.surface_result('BUILD_FAILURE') |
| raise |
| self.build_id = result.stdout['results'][0]['build']['id'] |
| |
| |
| def _get_bisect_config_for_tester(self): |
| """Copies the key-value pairs required by a tester bot to a new dict.""" |
| result = {} |
| required_test_properties = { |
| 'truncate_percent', |
| 'metric', |
| 'command', |
| 'test_type' |
| } |
| for k, v in self.bisector.bisect_config.iteritems(): |
| if k in required_test_properties: |
| result[k] = v |
| result['repeat_count'] = self.repeat_count |
| self._test_config = result |
| return result |
| |
| def _do_test(self): |
| """Triggers tests for a revision, either locally or via try job. |
| |
| If local testing is enabled (i.e. director/tester merged) then |
| the test will be run on the same machine. Otherwise, this posts |
| a request to buildbot to download and perf-test this build. |
| """ |
| if self.test_run_count: # pragma: no cover |
| self.repeat_count = max(MINIMUM_SAMPLE_SIZE, math.ceil( |
| self.test_run_count * 1.5)) - self.test_run_count |
| |
| api = self.bisector.api |
| perf_test_properties = { |
| 'properties': { |
| 'revision': self.top_revision.commit_hash, |
| 'parent_got_revision': self.top_revision.commit_hash, |
| 'parent_build_archive_url': self.build_url, |
| 'bisect_config': self._get_bisect_config_for_tester(), |
| 'job_name': self.job_name, |
| 'revision_ladder': self.revision_ladder, |
| 'deps_revision_overrides': self.revision_overrides, |
| }, |
| } |
| skip_download = self.bisector.last_tested_revision == self |
| self.bisector.last_tested_revision = self |
| overrides = perf_test_properties['properties'] |
| |
| def run_test_step_test_data(): |
| """Returns a single step data object when called. |
| |
| These are expected to be populated by the test_api. |
| """ |
| if api._test_data['run_results'].get(self.commit_hash): |
| return api._test_data['run_results'][self.commit_hash].pop(0) |
| return api._test_data['run_results']['default'] |
| |
| self._read_test_results(api.run_local_test_run( |
| overrides, skip_download=skip_download, |
| step_test_data=run_test_step_test_data |
| )) |
| |
| def _check_revision_good(self): # pragma: no cover |
| """Determines if a revision is good or bad. |
| |
| Returns: |
| True if the revision is either good or bad, False if it cannot be |
| determined from the available data. |
| """ |
| # Do not reclassify revisions. Important for reference range. |
| if self.good or self.bad: |
| return True |
| |
| lkgr = self.bisector.lkgr |
| fkbr = self.bisector.fkbr |
| if self.bisector.is_return_code_mode(): |
| if self.overall_return_code == lkgr.overall_return_code: |
| self.good = True |
| else: |
| self.bad = True |
| return True |
| diff_from_good = self.bisector.compare_revisions(self, lkgr) |
| diff_from_bad = self.bisector.compare_revisions(self, fkbr) |
| if (diff_from_good == NOT_SIGNIFICANTLY_DIFFERENT and |
| diff_from_bad == NOT_SIGNIFICANTLY_DIFFERENT): |
| # We have reached the max number of samples and have not established |
| # difference, give up. |
| raise bisect_exceptions.InconclusiveBisectException() |
| if (diff_from_good == SIGNIFICANTLY_DIFFERENT and |
| diff_from_bad == SIGNIFICANTLY_DIFFERENT): |
| # Multiple regressions. |
| # For now, proceed bisecting the biggest difference of the means. |
| dist_from_good = abs(self.mean - lkgr.mean) |
| dist_from_bad = abs(self.mean - fkbr.mean) |
| if dist_from_good > dist_from_bad: |
| # TODO(robertocn): Add way to handle the secondary regression |
| #self.bisector.handle_secondary_regression(self, fkbr) |
| self.bad = True |
| return True |
| else: |
| #self.bisector.handle_secondary_regression(lkgr, self) |
| self.good = True |
| return True |
| if diff_from_good == SIGNIFICANTLY_DIFFERENT: |
| self.bad = True |
| return True |
| elif diff_from_bad == SIGNIFICANTLY_DIFFERENT: |
| self.good = True |
| return True |
| # NEED_MORE_DATA |
| if self.test_run_count > MAX_TESTS_PER_REVISION: |
| raise bisect_exceptions.UntestableRevisionException( |
| 'Not enough data after testing %s %d times' % ( |
| self.revision_string(), self.test_run_count)) |
| return False |
| |
| |
| def revision_string(self): |
| if self._rev_str: |
| return self._rev_str |
| result = '' |
| if self.base_revision: # pragma: no cover |
| result += self.base_revision.revision_string() + ',' |
| commit = self.commit_hash[:10] |
| if self.depot_name == 'chromium': |
| try: |
| commit = str(self.bisector.api.m.commit_position |
| .chromium_commit_position_from_hash(self.commit_hash)) |
| except self.bisector.api.m.step.StepFailure: |
| pass # Failure to resolve a commit position is no reason to break. |
| result += '%s@%s' % (self.depot_name, commit) |
| self._rev_str = result |
| return self._rev_str |
| |
| def __repr__(self): # pragma: no cover |
| if not self.test_run_count: |
| return ('RevisionState(rev=%s), values=[]' % self.revision_string()) |
| if self.bisector.is_return_code_mode(): |
| return ('RevisionState(rev=%s, mean=%r, overall_return_code=%r, ' |
| 'std_dev=%r)') % (self.revision_string(), self.mean, |
| self.overall_return_code, self.std_dev) |
| return ('RevisionState(rev=%s, mean_value=%r, std_dev=%r)' % ( |
| self.revision_string(), self.mean, self.std_dev)) |
| |
| @property |
| def overall_return_code(self): |
| if self.bisector.is_return_code_mode(): |
| if self.return_codes: |
| if max(self.return_codes): |
| return 1 |
| return 0 |
| raise ValueError('overall_return_code needs non-empty sample' |
| ) # pragma: no cover |
| raise ValueError('overall_return_code only applies to return_code bisects' |
| ) # pragma: no cover |