blob: 61fe35fde293eaf8b72e73bad694c8f640587b3e [file]
# Copyright 2016 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 base64
import logging
from google.appengine.ext import ndb
from common.waterfall import buildbucket_client
from dto.flake_analysis_error import FlakeAnalysisError
from dto.int_range import IntRange
from gae_libs.model.versioned_model import VersionedModel
from libs import analysis_status
from libs import time_util
from model import result_status
from model import triage_status
from model.base_analysis import BaseAnalysis
from model.base_build_model import BaseBuildModel
from model.base_triaged_model import TriagedModel
from services.flake_failure import pass_rate_util
class DataPoint(ndb.Model):
# TODO(crbug.com/809218): Deprecate fields that refer to 'build.'
# The build number corresponding to this data point. Only relevant for
# analysis at the build level.
build_number = ndb.IntegerProperty(indexed=False)
# The url to the build page of the build whose artifacts were used to generate
# This data point.
build_url = ndb.StringProperty(indexed=False)
# The pass rate of the test when run against this commit.
# -1 means that the test doesn't exist at this commit/build.
pass_rate = ndb.FloatProperty(indexed=False)
# The ID of the swarming task responsible for generating this data.
task_ids = ndb.StringProperty(indexed=False, repeated=True)
# The commit position of this data point.
commit_position = ndb.IntegerProperty(indexed=False)
# The git hash of this data point.
git_hash = ndb.StringProperty(indexed=False)
# TODO(crbug.com/839620): Deprecate previous_build_commit_position,
# previous_build_git_hash, and blame_list.
# The commit position of the build preceding this one. Only relevant if this
# data point is generated at the build level.
previous_build_commit_position = ndb.IntegerProperty(indexed=False)
# The git hash of the data point 1 build before this one. Only relevant if
# this data point is generated as the result of a flake swarming task.
previous_build_git_hash = ndb.StringProperty(indexed=False)
# The list of revisions between this build and the previous build. Only
# relevant if this data point is generated as the result of a flake swarming
# task.
blame_list = ndb.StringProperty(repeated=True)
# The URL to the try job that generated this data point, if any.
try_job_url = ndb.StringProperty(indexed=False)
# A flag indicates whether the checked build has valid artifact.
# This flag is only for build level data points.
has_valid_artifact = ndb.BooleanProperty(indexed=False, default=True)
# The number of iterations run to determine this data point's pass rate.
iterations = ndb.IntegerProperty(indexed=False)
# The total seconds that these iterations took to compute.
elapsed_seconds = ndb.IntegerProperty(indexed=False, default=0)
# The number of times a swarming task had an error while generating this
# data point.
failed_swarming_task_attempts = ndb.IntegerProperty(indexed=False, default=0)
@staticmethod
def Create(build_number=None,
build_url=None,
pass_rate=None,
task_ids=None,
commit_position=None,
git_hash=None,
previous_build_commit_position=None,
previous_build_git_hash=None,
blame_list=None,
try_job_url=None,
has_valid_artifact=True,
iterations=None,
elapsed_seconds=0,
failed_swarming_task_attempts=0):
data_point = DataPoint()
data_point.build_url = build_url
data_point.build_number = build_number
data_point.pass_rate = pass_rate
task_ids = task_ids or []
data_point.task_ids = task_ids
data_point.commit_position = commit_position
data_point.git_hash = git_hash
data_point.previous_build_commit_position = previous_build_commit_position
data_point.previous_build_git_hash = previous_build_git_hash
data_point.blame_list = blame_list or []
data_point.try_job_url = try_job_url
data_point.has_valid_artifact = has_valid_artifact
data_point.iterations = iterations
data_point.elapsed_seconds = elapsed_seconds
data_point.failed_swarming_task_attempts = failed_swarming_task_attempts
return data_point
def GetSwarmingTaskId(self):
"""Returns the last element in the list.
Guaranteed to be within flake_constants.CONVERGENCE_PERCENT of the true pass
rate if the pass rate converged.
"""
return self.task_ids[-1] if self.task_ids else None
def GetCommitPosition(self, revision):
"""Gets the commit position of a revision within blame_list.
Args:
revision (str): The revision to search for.
Returns:
commit_position (int): The calculated commit position of revision.
"""
assert revision in self.blame_list
for i in range(0, len(self.blame_list)): # pragma: no branch
if revision == self.blame_list[i]:
return i + self.previous_build_commit_position + 1
def GetRevisionAtCommitPosition(self, commit_position):
"""Gets the corresponding revision to commit_position.
Args:
commit_position (int): The commit position for which to find the
corresponding revision within self.blame_list.
Returns:
revision (str): The git revision corresponding to commit_position.
"""
length = len(self.blame_list)
assert (commit_position > self.commit_position - length and
commit_position <= self.commit_position)
return self.blame_list[length - (self.commit_position - commit_position) -
1]
def GetDictOfCommitPositionAndRevision(self):
"""Gets a dict of commit_position:revision items for this data_point."""
blamed_cls = {}
commit_position = self.commit_position
for i in xrange(len(self.blame_list) - 1, -1, -1):
blamed_cls[commit_position] = self.blame_list[i]
commit_position -= 1
return blamed_cls
class MasterFlakeAnalysis(BaseAnalysis, BaseBuildModel, VersionedModel,
TriagedModel):
"""Represents an analysis of a flaky test on a Waterfall test cycle."""
@ndb.ComputedProperty
def step_name(self):
return self.key.pairs()[0][1].split('/')[3]
@ndb.ComputedProperty
def canonical_step_name(self):
return self.step_name.split(' on ')[0]
@ndb.ComputedProperty
def test_name(self):
return base64.urlsafe_b64decode(self.key.pairs()[0][1].split('/')[4])
@property
def error_message(self):
if not self.error:
return None
return self.error.get('message')
@property
def iterations_to_rerun(self):
if not self.algorithm_parameters:
return -1
return (self.algorithm_parameters.get('swarming_rerun',
{}).get('iterations_to_rerun') or
self.algorithm_parameters.get('iterations_to_rerun'))
@staticmethod
def _CreateAnalysisId(master_name, builder_name, build_number, step_name,
test_name):
encoded_test_name = base64.urlsafe_b64encode(test_name)
return '%s/%s/%s/%s/%s' % (master_name, builder_name, build_number,
step_name, encoded_test_name)
@staticmethod
def GetBuildConfigurationFromKey(master_flake_analysis_key):
"""Extracts master_name and builder_name from key."""
if not master_flake_analysis_key:
return None, None
components = master_flake_analysis_key.pairs()[0][1].split('/')
master_name = components[0]
builder_name = components[1]
return master_name, builder_name
# Arguments number differs from overridden method - pylint: disable=W0221
@classmethod
def Create(cls, master_name, builder_name, build_number, step_name,
test_name): # pragma: no cover.
# TODO(wylieb): Populate original_* fields with these, add test case for
# Create.
return super(MasterFlakeAnalysis, cls).Create(
MasterFlakeAnalysis._CreateAnalysisId(
master_name, builder_name, build_number, step_name, test_name))
def GetError(self):
"""Returns an analysis' error or a generic one."""
return self.error or FlakeAnalysisError(
title='Flake analysis encountered an unknown error',
description='unknown').ToSerializable()
# Arguments number differs from overridden method - pylint: disable=W0221
@classmethod
def GetVersion(cls,
master_name,
builder_name,
build_number,
step_name,
test_name,
version=None): # pragma: no cover.
return super(MasterFlakeAnalysis, cls).GetVersion(
key=MasterFlakeAnalysis._CreateAnalysisId(
master_name, builder_name, build_number, step_name, test_name),
version=version)
def LogInfo(self, message):
logging.info('%s/%s/%s/%s/%s %s', self.master_name, self.builder_name,
self.build_number, self.step_name, self.test_name, message)
def LogWarning(self, message):
logging.warning('%s/%s/%s/%s/%s %s', self.master_name, self.builder_name,
self.build_number, self.step_name, self.test_name, message)
def LogError(self, message):
logging.error('%s/%s/%s/%s/%s %s', self.master_name, self.builder_name,
self.build_number, self.step_name, self.test_name, message)
def CanRunHeuristicAnalysis(self):
"""Determies whether heuristic analysis can be attempted."""
already_run_statuses = [
analysis_status.SKIPPED, analysis_status.COMPLETED,
analysis_status.ERROR
]
return ((self.suspected_build_id is not None or
self.suspected_flake_build_number is not None) and
self.heuristic_analysis_status not in already_run_statuses)
def UpdateTriageResult(self,
triage_result,
suspect_info,
user_name,
version_number=None):
"""Updates triage result for a flake analysis.
If there is culprit for the analysis, triage will be at CL level;
otherwise the triage will be for suspected_flake_build.
"""
super(MasterFlakeAnalysis, self).UpdateTriageResult(
triage_result, suspect_info, user_name, version_number=version_number)
if triage_result == triage_status.TRIAGED_CORRECT:
self.result_status = result_status.FOUND_CORRECT
if suspect_info.get('culprit_revision'):
self.correct_culprit = True
else:
self.result_status = result_status.FOUND_INCORRECT
if suspect_info.get('culprit_revision'):
self.correct_culprit = False
def GetDataPointOfSuspectedBuild(self):
"""Gets the corresponding data point to the suspected flake build."""
if self.suspected_flake_build_number is not None:
for data_point in self.data_points:
if data_point.build_number == self.suspected_flake_build_number:
return data_point
return None
def GetLatestRegressionRange(self):
"""Gets the latest stable -> flaky commit positions in data_points.
Returns:
(IntRange): The commit position of the latest
stable data_point and commit position of the earliest subsequent flaky
data point. Either point can be None if no flaky or stable points are
found.
"""
if not self.data_points:
return IntRange(lower=None, upper=None)
if len(self.data_points) == 1:
data_point = self.data_points[0]
if pass_rate_util.IsStableDefaultThresholds(data_point.pass_rate):
# A lower bound stable is found, but no upper bound. The caller of this
# function should interpret this as the flakiness being unreproducible.
return IntRange(lower=data_point.commit_position, upper=None)
# The flakiness is reproducible, but no lower bound (stable) has been
# identified yet.
return IntRange(lower=None, upper=data_point.commit_position)
# For the latest regression range, sort in reverse order by commit position.
data_points = sorted(
self.data_points, key=lambda k: k.commit_position, reverse=True)
# Disregard the data point created by the check for recent flakiness (if
# any).
# TODO(crbug.com/843846): Remove this data sanitization once change for
# not appending that data point is committed.
if (data_points and
pass_rate_util.IsStableDefaultThresholds(data_points[0].pass_rate)):
data_points = data_points[1:]
# Identify the adjacent flaky and stable data points with the highest commit
# positions.
latest_stable_index = None
for i, data_point in enumerate(data_points):
if pass_rate_util.IsStableDefaultThresholds(data_point.pass_rate):
latest_stable_index = i
break
if latest_stable_index is None:
# All data points are flaky. The caller should interpret this as no
# findings yet.
return IntRange(lower=None, upper=data_points[-1].commit_position)
# A regression range has been identified.
assert latest_stable_index > 0, (
'Non-reproducible flaky tests should only have 1 data point')
adjacent_flaky_data_point = data_points[latest_stable_index - 1]
assert not pass_rate_util.IsStableDefaultThresholds(
adjacent_flaky_data_point.pass_rate)
return IntRange(
lower=data_points[latest_stable_index].commit_position,
upper=adjacent_flaky_data_point.commit_position)
def Reset(self):
super(MasterFlakeAnalysis, self).Reset()
self.original_master_name = None
self.original_builder_name = None
self.original_build_number = None
self.original_step_name = None
self.original_test_name = None
self.bug_id = None
self.error = None
self.correct_regression_range = None
self.correct_culprit = None
self.algorithm_parameters = None
self.suspected_build_id = None
self.suspected_flake_build_number = None
self.suspect_urlsafe_keys = []
self.culprit_urlsafe_key = None
self.try_job_status = None
self.data_points = []
self.result_status = None
self.last_attempted_build_number = None
self.last_attempted_swarming_task_id = None
self.last_attempted_revision = None
self.heuristic_analysis_status = None
# Reset booleans that track the actions taken by this analysis.
self.has_filed_bug = False
self.has_commented_on_bug = False
self.has_commented_on_cl = False
self.has_created_autorevert = False
self.has_submitted_autorevert = False
def GetCommitPositionOfBuild(self, build_number):
"""Gets the commit position of a build in self.data_points if available.
Searches self.data_points for the data point with the corresponding build
number and returns its commit position if found, else None. Note that data
points generated as a result of try jobs should not have build_number set.
Args:
build_number (int): The build number to find the matching data point.
Returns:
The commit position of the data point with the matching build number.
"""
for data_point in self.data_points:
# Skip try job data points since they should not have build_number.
if (data_point.build_number == build_number and
data_point.try_job_url is None):
return data_point.commit_position
return None
# TODO(crbug.com/798231): Remove once build level analysis is removed.
def GetDataPointsWithinBuildNumberRange(self, lower_bound_build_number,
upper_bound_build_number):
"""Filters data_points by lower and upper bound build numbers.
All data points within the build number range will be returned, including
data points created by try jobs.
Args:
lower_bound_build_number (int): The earlist build number a data point can
have not to be filtered out. If None is passed, defaults to 0.
upper_bound_build_number (int): The latest build number a data point can
have not to be filtered out. If none is passed, defaults to infinity.
Returns:
A list of DataPoints filtered by the input build numbers.
"""
if lower_bound_build_number is None and upper_bound_build_number is None:
return self.data_points
lower_bound = self.GetCommitPositionOfBuild(lower_bound_build_number) or 0
upper_bound = self.GetCommitPositionOfBuild(upper_bound_build_number)
return self.GetDataPointsWithinCommitPositionRange(
IntRange(lower=lower_bound, upper=upper_bound))
def GetDataPointsWithinCommitPositionRange(self, int_range):
"""Filters data_points by lower and upper bound commit positions.
Args:
int_range (IntRange): The upper and lower bound commit positions to
include in the returned results.
Returns:
A list of DataPoints filtered by the input commit positions.
"""
lower = int_range.lower
upper = int_range.upper if int_range.upper is not None else float('inf')
def PositionInBounds(data_point):
return (data_point.commit_position >= lower and
data_point.commit_position <= upper)
return filter(PositionInBounds, self.data_points)
def RemoveDataPointWithBuildNumber(self, build_number):
self.data_points = filter(lambda x: x.build_number != build_number,
self.data_points)
def RemoveDataPointWithCommitPosition(self, commit_position):
self.data_points = filter(lambda x: x.commit_position != commit_position,
self.data_points)
def FindMatchingDataPointWithCommitPosition(self, commit_position):
"""Finds the data point with the same commit_position as the given one.
Args:
commit_position (int): DataPoint with the matching commit position to
search for in the list.
Returns:
A DataPoint with the matching commit_position if found, else None.
"""
if commit_position is None:
return None
return next((data_point for data_point in self.data_points
if data_point.commit_position == commit_position), None)
def FindMatchingDataPointWithBuildNumber(self, build_number):
"""Finds the data point with the same build_number as the given one.
Args:
build_number (int): DataPoint with the matching build number to search for
in the list.
Returns:
A DataPoint with the matching build_number if found, else None.
"""
if build_number is None:
return None
return next((data_point for data_point in self.data_points
if data_point.build_number == build_number), None)
def InitializeRunning(self):
"""Sets up necessary information for an analysis when it begins."""
if self.status != analysis_status.RUNNING:
self.Update(
start_time=time_util.GetUTCNow(), status=analysis_status.RUNNING)
def UpdateSuspectedBuild(self, lower_bound_target, upper_bound_target):
"""Sets the suspected build number if appropriate.
A suspected build cycle can be set when a regression range is identified
and spans at most a single build cycle.
Args:
lower_bound_target (IsolatedTarget): The earlier isolated target whose
commit position to check.
upper_bound_build (IsolatedTarget): The later isolated target whose commit
position to check.
"""
lower_bound = lower_bound_target.commit_position
upper_bound = upper_bound_target.commit_position
assert upper_bound > lower_bound, ((
'Upper bound target commit position {} must be greater than lower '
'bound target {} commit position').format(upper_bound, lower_bound))
lower_bound_data_point = self.FindMatchingDataPointWithCommitPosition(
lower_bound)
upper_bound_data_point = self.FindMatchingDataPointWithCommitPosition(
upper_bound)
if (self.suspected_flake_build_number is None and
self.suspected_build_id is None and lower_bound_data_point and
upper_bound_data_point):
assert pass_rate_util.IsStableDefaultThresholds(
lower_bound_data_point.pass_rate), (
'Lower bound must be stable in order to have a suspect')
assert not pass_rate_util.IsStableDefaultThresholds(
upper_bound_data_point.pass_rate), (
'Upper bound must be flaky in order to have a suspect')
build_id = upper_bound_target.build_id
# Temporarily maintain suspected_flake_build_number to support legacy
# analyses.
suspected_build_number = buildbucket_client.GetBuildNumberFromBuildId(
build_id)
self.Update(
suspected_build_id=build_id,
suspected_flake_build_number=suspected_build_number)
def UpdateSuspectedBuildUsingBuildInfo(self, lower_bound_build,
upper_bound_build):
"""Sets the suspected build number if appropriate.
A suspected build cycle can be set when a regression range is identified
and spans at most a single build cycle.
TODO(crbug.com/872992): This function should be deprecated as soon as the
LUCI migration is 100% complete.
Args:
lower_bound_build (BuildInfo): The earlier build whose commit position to
check.
upper_bound_build (BuildInfo): The later build whose commit
position to check, assumed to be 1 build cycle apart from
lower_bound_build.
"""
lower_bound_commit_position = lower_bound_build.commit_position
upper_bound_commit_position = upper_bound_build.commit_position
assert upper_bound_commit_position > lower_bound_commit_position, (
'Upper bound {} must be > lower bound {}'.format(
upper_bound_commit_position, lower_bound_commit_position))
lower_bound_data_point = self.FindMatchingDataPointWithCommitPosition(
lower_bound_commit_position)
upper_bound_data_point = self.FindMatchingDataPointWithCommitPosition(
upper_bound_commit_position)
if (self.suspected_flake_build_number is None and lower_bound_data_point and
upper_bound_data_point): # pragma: no branch
assert pass_rate_util.IsStableDefaultThresholds(
lower_bound_data_point.pass_rate), (
'Lower bound build must be stable in order to have a suspect')
assert not pass_rate_util.IsStableDefaultThresholds(
upper_bound_data_point.pass_rate), (
'Upper bound build must be flaky in order to have a suspect')
self.Update(suspected_flake_build_number=upper_bound_build.build_number)
def Update(self, **kwargs):
"""Updates fields according to what's specified in kwargs.
Fields specified in kwargs will be updated accordingly, while those not
present in kwargs will be untouched.
Args:
algorithm_parameters (dict): The analysis' algorithm parameters.
confidence_in_culprit (float): Confidence score for the suspected culprit
CL, if any.
culprit_urlsafe_key (str): The urlsafe-key coresponding to a FlakeCulprit
that caused flakiness.
end_time (datetime): The timestamp that the overall analysis is completed.
error (dict): Dict containing error information.
last_attempted_swarming_revision (str): The last attempted try job
revision.
last_attempted_swarming_revision (str): The ID of the last attempted
swarming task.
result_status (int): The triage result status of this analysis.
status (int): The status of the regression-range identification analysis.
start_time (datetime): The timestamp that the overall analysis started.
suspected_build (int): The suspected build number.
try_job_status (int): The status of try job/culprit analysis.
"""
any_changes = False
for arg, value in kwargs.iteritems():
current_value = getattr(self, arg, None)
if current_value != value:
setattr(self, arg, value)
any_changes = True
if any_changes:
self.put()
# The original build/step/test in which a flake actually occurred.
# A CQ trybot step has to be mapped to a Waterfall buildbot step.
# A gtest suite.PRE_PRE_test has to be normalized to suite.test.
original_master_name = ndb.StringProperty(indexed=True)
original_builder_name = ndb.StringProperty(indexed=True)
original_build_number = ndb.IntegerProperty(indexed=True)
original_step_name = ndb.StringProperty(indexed=True)
original_test_name = ndb.StringProperty(indexed=True)
# The bug id in which this flake is reported.
bug_id = ndb.IntegerProperty(indexed=True)
# A bit to track if a bug filing has been attempted.
has_attempted_filing = ndb.BooleanProperty(default=False)
# Error code and message, if any.
error = ndb.JsonProperty(indexed=False)
# Boolean whether the suspected regression range/build number is correct.
correct_regression_range = ndb.BooleanProperty(indexed=True)
# Boolean whether the suspected CL for found in the regression range
# is correct.
correct_culprit = ndb.BooleanProperty(indexed=True)
# The look back algorithm parameters that were used, as specified in Findit's
# configuration. For example,
# {
# 'iterations_to_rerun': 100,
# 'lower_flake_threshold': 0.02,
# 'max_build_numbers_to_look_back': 500,
# 'max_flake_in_a_row': 4,
# 'max_stable_in_a_row': 4,
# 'upper_flake_threshold': 0.98
# }
algorithm_parameters = ndb.JsonProperty(indexed=False)
# The suspected build number to have introduced the flakiness.
# TODO(crbug.com/799324): Remove once build numbers are deprecated in LUCI.
suspected_flake_build_number = ndb.IntegerProperty()
# The build id of the build cycle suspected to contain the culprit.
suspected_build_id = ndb.IntegerProperty()
# The confidence in the suspected CL to have introduced the flakiness.
confidence_in_culprit = ndb.FloatProperty(indexed=False)
# TODO(crbug.com/799308): Use KeyProperty instead.
# The urlsafe key to a FlakeCulprit associated with the try job results.
culprit_urlsafe_key = ndb.StringProperty(indexed=False)
# TODO(crbug.com/799308): Use KeyProperty instead.
# A list of url-safe keys to FlakeCulprits identified by heuristic analysis.
suspect_urlsafe_keys = ndb.StringProperty(repeated=True)
# Heuristic anlysis status. Can be PENDING if not yet ran, SKIPPED if wil not
# be run, COMPLETED or ERROR if already ran.
heuristic_analysis_status = ndb.IntegerProperty(indexed=False)
# The status of try jobs, if any. None if analysis is still performing
# swarming reruns, SKIPPED if try jobs will not be triggered, RUNNING when
# the first try job is triggered, COMPLETED when the last one finishes, and
# ERROR if any try job ends with error.
try_job_status = ndb.IntegerProperty(indexed=False)
# The data points used to plot the flakiness graph build over build.
data_points = ndb.LocalStructuredProperty(
DataPoint, repeated=True, compressed=True)
# Whether the analysis was triggered by a manual request through check flake,
# Findit's automatic analysis upon detection, or Findit API.
triggering_source = ndb.IntegerProperty(default=None, indexed=True)
# Who triggered the analysis. Used for differentiating between manual and
# automatic runs, and determining the most active users to gather feedback.
triggering_user_email = ndb.StringProperty(default=None, indexed=False)
# Whether the user email is obscured.
triggering_user_email_obscured = ndb.BooleanProperty(
default=False, indexed=True)
# Overall conclusion of analysis result for the flake. Found untriaged, Found
# Correct, etc. used to filter what is displayed on the check flake dashboard.
result_status = ndb.IntegerProperty(indexed=True)
# The build number corresponding to the last attempted swarming task.
last_attempted_build_number = ndb.IntegerProperty(indexed=False)
# The task id of the last-attempted swarming task.
last_attempted_swarming_task_id = ndb.StringProperty(indexed=False)
# The revision the last-attempted try job tried to run on.
last_attempted_revision = ndb.StringProperty(indexed=False)
# Store the root pipeline id to look up the pipeline from the analysis.
root_pipeline_id = ndb.StringProperty(indexed=False)
# How many times the swarming task has been attempted (and failed) for a
# particular build during an analysis. Reset when the build number is changed.
swarming_task_attempts_for_build = ndb.IntegerProperty(
default=0, indexed=False)
# Track if this analysis has created a bug.
has_filed_bug = ndb.BooleanProperty(default=False, indexed=False)
# Track if this analysis has commented on a bug.
has_commented_on_bug = ndb.BooleanProperty(default=False, indexed=False)
# Track if this analysis has commented on a cl.
has_commented_on_cl = ndb.BooleanProperty(default=False, indexed=False)
# Track if this analysis has created a revert for a test.
has_created_autorevert = ndb.BooleanProperty(default=False, indexed=False)
# Track if this analysis has submitted a revert cl for a test.
has_submitted_autorevert = ndb.BooleanProperty(default=False, indexed=True)
# Track the time the autorevert has been submitted.
autorevert_submission_time = ndb.DateTimeProperty(indexed=True)