blob: 42fd2c01a40683544d4141b1174a2d84422491b9 [file]
package analyzer
import (
"bytes"
"compress/zlib"
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/url"
"sort"
"strings"
"time"
"infra/appengine/sheriff-o-matic/som/analyzer/step"
"infra/appengine/sheriff-o-matic/som/client"
"infra/appengine/sheriff-o-matic/som/model"
"infra/monitoring/messages"
"cloud.google.com/go/bigquery"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/common/data/stringset"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/gae/service/datastore"
"go.chromium.org/luci/gae/service/info"
"go.chromium.org/luci/gae/service/memcache"
"go.chromium.org/luci/grpc/grpcutil"
"google.golang.org/api/iterator"
"google.golang.org/grpc/codes"
)
const bqMemcacheFormat = "bq-%s"
const selectFrom = `
SELECT
Project,
Bucket,
Builder,
BuilderGroup,
StepName,
TestNamesFingerprint,
TestNamesTrunc,
NumTests,
BuildIdBegin,
BuildIdEnd,
BuildNumberBegin,
BuildNumberEnd,
CPRangeOutputBegin,
CPRangeOutputEnd,
CPRangeInputBegin,
CPRangeInputEnd,
CulpritIdRangeBegin,
CulpritIdRangeEnd,
StartTime,
BuildStatus
FROM
` + "`%s.%s.sheriffable_failures`"
const selectFromWhere = selectFrom + `
WHERE
`
// TODO (nqmtuan): Filter the critical for other projects
// But firstly make sure it is working with chrome os first
const crosFailuresQuery = selectFromWhere + `
project = "chromeos"
AND bucket IN ("postsubmit")
AND (critical != "NO" OR critical is NULL)
`
const fuchsiaFailuresQuery = selectFromWhere + `
Project = %q
AND Bucket = "global.roller"
AND Builder NOT LIKE "%%bisect%%"
LIMIT
1000
`
var androidFilterFunc = func(r failureRow) bool {
if r.Project != "chrome" && r.Project != "chromium" {
return false
}
excludedBuckets := []string{"try", "findit"}
if sliceContains(excludedBuckets, r.Bucket) {
return false
}
builderGroup := r.BuilderGroup.String()
if sliceContains([]string{"internal.client.clank", "internal.client.clank_tot", "chromium.android"}, builderGroup) {
return true
}
if builderGroup == "chromium" && r.Builder == "Android" {
return true
}
if builderGroup == "chromium.webkit" && sliceContains([]string{"Android Builder", "Webkit Android (Nexus4)"}, r.Builder) {
return true
}
validBuilders := []string{
"android-androidx-packager",
"android-arm-official-tests",
"android-arm64-official-tests",
"android-arm-beta-tests",
"android-arm64-beta-tests",
"android-arm-stable-tests",
"android-arm64-stable-tests",
"android-arm-beta",
"android-arm64-beta",
"android-arm64-stable",
"android-arm64-stable",
"android-arm-stable",
"android-asan",
}
return sliceContains(validBuilders, r.Builder)
}
var chromiumFilterFunc = func(r failureRow) bool {
if r.Project != "chrome" && r.Project != "chromium" {
return false
}
builderGroup := r.BuilderGroup.String()
validBuilderGroups := []string{
"chrome",
"chromium",
"chromium.chromiumos",
"chromium.linux",
"chromium.mac",
"chromium.memory",
"chromium.win",
}
return sliceContains(validBuilderGroups, builderGroup) && r.Bucket == "ci"
}
var chromiumGPUFilterFunc = func(r failureRow) bool {
if r.Project != "chrome" && r.Project != "chromium" {
return false
}
if r.Bucket == "findit" {
return false
}
validBuilderGroups := []string{
"chromium.gpu",
"chromium.gpu.fyi",
"chromium.swangle",
}
return sliceContains(validBuilderGroups, r.BuilderGroup.String())
}
var chromiumPerfFilterFunc = func(r failureRow) bool {
if r.Project != "chrome" && r.Project != "chromium" {
return false
}
if strings.Contains(r.Builder, "bisect") {
return false
}
excludedBuckets := []string{"try", "cq", "staging", "general", "findit"}
if sliceContains(excludedBuckets, r.Bucket) {
return false
}
return (r.Project == "chromium.perf" || r.BuilderGroup.String() == "chromium.perf") && (!r.BuilderGroup.Valid || !strings.HasSuffix(r.BuilderGroup.String(), ".fyi"))
}
var iosFilterFunc = func(r failureRow) bool {
if r.Project != "chrome" && r.Project != "chromium" {
return false
}
if r.Bucket == "findit" {
return false
}
if r.Project == "chrome" && r.BuilderGroup.String() == "internal.bling.main" {
return true
}
validBuilders := []string{
"ios-device",
"ios-simulator",
"ios-simulator-full-configs",
"ios-simulator-noncq",
}
if r.Project == "chromium" && r.BuilderGroup.String() == "chromium.mac" && sliceContains(validBuilders, r.Builder) {
return true
}
return false
}
var chromeBrowserReleaseFilterFunc = func(r failureRow) bool {
// TODO (nqmtuan): Remove the first part of the OR condition when we are post m85
return (r.Project == "chromium" && strings.HasPrefix(r.Bucket, "ci-m")) || (strings.HasPrefix(r.Project, "chromium-m") && r.Bucket == "ci")
}
var chromiumClangFilterFunc = func(r failureRow) bool {
if strings.Contains(r.Builder, "bisect") {
return false
}
excludedBuckets := []string{"try", "cq", "staging", "general", "findit"}
if sliceContains(excludedBuckets, r.Bucket) {
return false
}
return (r.Project == "chromium.clang" || r.BuilderGroup.String() == "chromium.clang") && (!r.BuilderGroup.Valid || !strings.HasSuffix(r.BuilderGroup.String(), ".fyi"))
}
type failureRow struct {
TestNamesFingerprint bigquery.NullInt64
TestNamesTrunc bigquery.NullString
NumTests bigquery.NullInt64
StepName string
BuilderGroup bigquery.NullString
Builder string
Bucket string
Project string
BuildIDBegin bigquery.NullInt64
BuildIDEnd bigquery.NullInt64
BuildNumberBegin bigquery.NullInt64
BuildNumberEnd bigquery.NullInt64
CPRangeInputBegin *GitCommit
CPRangeInputEnd *GitCommit
CPRangeOutputBegin *GitCommit
CPRangeOutputEnd *GitCommit
CulpritIDRangeBegin bigquery.NullInt64
CulpritIDRangeEnd bigquery.NullInt64
StartTime bigquery.NullTimestamp
BuildStatus string
}
// GitCommit represents a struct column for BQ query results.
type GitCommit struct {
Project bigquery.NullString
Ref bigquery.NullString
Host bigquery.NullString
ID bigquery.NullString
Position bigquery.NullInt64
}
type bqBucket struct {
Project string
Bucket string
}
type bqBuilder struct {
Project string
Bucket string
Builder string
}
// This type is a catch-all for every kind of failure. In a better,
// simpler design we wouldn't have to use this but it's here to make
// the transition from previous analyzer logic easier.
type bqFailure struct {
Name string `json:"step"`
kind string
severity messages.Severity
Tests []step.TestWithResult `json:"tests"`
NumFailingTests int64 `json:"num_failing_tests"`
}
func (b *bqFailure) Signature() string {
return b.Name
}
func (b *bqFailure) Kind() string {
return b.kind
}
func (b *bqFailure) Severity() messages.Severity {
return b.severity
}
func (b *bqFailure) Title(bses []*messages.BuildStep) string {
f := bses[0]
prefix := fmt.Sprintf("%s failing", f.Step.Name)
if b.NumFailingTests > 0 {
prefix = fmt.Sprintf("%s (%d tests)", prefix, b.NumFailingTests)
}
if len(bses) == 1 {
return fmt.Sprintf("%s on %s/%s", prefix, f.BuilderGroup.Name(), f.Build.BuilderName)
}
return fmt.Sprintf("%s on multiple builders", prefix)
}
// Generates the BigQuery SQL for the specified tree against the app environment specified
// in appID (ex: "sheriff-o-matic-staging" vs. "sheriff-o-matic").
func generateSQLQuery(ctx context.Context, tree string, appID string) (string, error) {
bbProjectFilter := getBuildBucketProjectFilterFromTree(ctx, tree)
if tree == "chromeos" {
return fmt.Sprintf(crosFailuresQuery, appID, "chromeos"), nil
}
if tree == "fuchsia" {
return fmt.Sprintf(fuchsiaFailuresQuery, appID, "fuchsia", bbProjectFilter), nil
}
return "", fmt.Errorf("invalid tree %q", tree)
}
// GetBigQueryAlerts generates alerts for currently failing build steps, using
// BigQuery to do most of the heavy lifting.
// Note that this returns alerts for all failing steps, so filtering should
// be applied on the return value.
// TODO: Some post-bq result merging with heuristics:
// - Merge alerts for sets of multiple failing steps. Currently will return one alert
// for each failing step on a builder. If step_a and step_b are failing on the same
// builder or set of builders, they should be merged into a single alert.
func GetBigQueryAlerts(ctx context.Context, tree string) ([]*messages.BuildFailure, error) {
var failureRows []failureRow
var err error
if shouldUseCache(tree) {
failureRows, err = getFailureRowsForTree(ctx, tree)
if err != nil {
return nil, err
}
} else {
appID := getAppID(ctx)
queryStr, err := generateSQLQuery(ctx, tree, appID)
if err != nil {
return nil, err
}
failureRows, err = getFailureRowsForQuery(ctx, queryStr)
if err != nil {
return nil, err
}
}
// TODO (nqmtuan): Remove this condition once crbug.com/1152170 is fixed.
if tree != "fuchsia" {
filteredRows, err := filterDeletedBuilders(ctx, failureRows)
if err != nil {
// Probably something is wrong with buildbucket, but this is not critical.
// We should log the error and proceed.
logging.Errorf(ctx, "error when filtering deleted builders: %v", err)
} else {
failureRows = filteredRows
}
}
return processBQResults(ctx, failureRows)
}
func shouldUseCache(tree string) bool {
trees := []string{"android", "chromium", "chromium.gpu", "ios", "chromium.perf", "chrome_browser_release", "chromium.clang"}
return sliceContains(trees, tree)
}
func getFilterFuncForTree(tree string) (func(failureRow) bool, error) {
switch tree {
case "android":
return androidFilterFunc, nil
case "chromium":
return chromiumFilterFunc, nil
case "chromium.gpu":
return chromiumGPUFilterFunc, nil
case "chromium.perf":
return chromiumPerfFilterFunc, nil
case "ios":
return iosFilterFunc, nil
case "chrome_browser_release":
return chromeBrowserReleaseFilterFunc, nil
case "chromium.clang":
return chromiumClangFilterFunc, nil
default:
return nil, fmt.Errorf("could not find filter function for tree %s", tree)
}
}
func getFailureRowsForTree(ctx context.Context, tree string) ([]failureRow, error) {
allFailureRows, err := retrieveFailureRowsFromMemcache(ctx, "chrome")
if err != nil {
return nil, err
}
filterFunc, err := getFilterFuncForTree(tree)
if err != nil {
return nil, err
}
failureRows := []failureRow{}
for _, row := range allFailureRows {
if filterFunc(row) {
failureRows = append(failureRows, row)
}
}
return failureRows, nil
}
func generateQueryForProject(appID string, project string) string {
return fmt.Sprintf(selectFrom, appID, project)
}
// QueryBQForProject queries the sheriffable_failures for a project and stores the result in memcache.
func QueryBQForProject(ctx context.Context, project string) error {
appID := getAppID(ctx)
queryStr := generateQueryForProject(appID, project)
failureRows, err := getFailureRowsForQuery(ctx, queryStr)
if err != nil {
return err
}
return storeFailureRowsToMemcache(ctx, failureRows, project)
}
func getFailureRowsForQuery(ctx context.Context, queryStr string) ([]failureRow, error) {
failureRows := []failureRow{}
appID := getAppID(ctx)
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
client, err := bigquery.NewClient(ctx, appID)
if err != nil {
return nil, err
}
logging.Infof(ctx, "query: %s", queryStr)
q := client.Query(queryStr)
it, err := q.Read(ctx)
if err != nil {
return failureRows, err
}
for {
var r failureRow
err := it.Next(&r)
if err == iterator.Done {
break
}
if err != nil {
return failureRows, err
}
failureRows = append(failureRows, r)
}
return failureRows, nil
}
func getAppID(ctx context.Context) string {
appID := info.AppID(ctx)
logging.Infof(ctx, "app_id: %s", appID)
if appID == "None" {
appID = "sheriff-o-matic-staging"
}
return appID
}
// Queries BuildBucketProjectFilter from datastore on specified tree.
// Return the treeName if BuildBucketProjectFilter is not set.
func getBuildBucketProjectFilterFromTree(c context.Context, treeName string) string {
q := datastore.NewQuery("Tree")
trees := []*model.Tree{}
if err := datastore.GetAll(c, q, &trees); err == nil {
for _, tree := range trees {
if tree.Name == treeName && tree.BuildBucketProjectFilter != "" {
return strings.TrimSpace(tree.BuildBucketProjectFilter)
}
}
}
return treeName
}
func generateBuilderURL(project string, bucket string, builderName string) string {
return fmt.Sprintf("https://ci.chromium.org/p/%s/builders/%s/%s", project, bucket, url.PathEscape(builderName))
}
func generateBuildURL(project string, bucket string, builderName string, buildID bigquery.NullInt64) string {
if buildID.Valid {
return fmt.Sprintf("%s/b%d", generateBuilderURL(project, bucket, builderName), buildID.Int64)
}
return "" // Go does not allow null value :(
}
func processBQResults(ctx context.Context, failureRows []failureRow) ([]*messages.BuildFailure, error) {
ret := []*messages.BuildFailure{}
for _, r := range failureRows {
ab := generateAlertedBuilder(ctx, r)
regressionRanges := getRegressionRanges(ab.LatestPassingRev, ab.FirstFailingRev)
// Process tests.
reason := &bqFailure{
Name: r.StepName,
kind: "basic",
severity: messages.ReliableFailure,
}
if r.TestNamesFingerprint.Valid {
reason.kind = "test"
testNames := strings.Split(r.TestNamesTrunc.StringVal, "\n")
sort.Strings(testNames)
for _, testName := range testNames {
reason.Tests = append(reason.Tests, step.TestWithResult{
TestName: testName,
})
}
reason.NumFailingTests = r.NumTests.Int64
}
bf := &messages.BuildFailure{
StepAtFault: &messages.BuildStep{
Step: &messages.Step{
Name: r.StepName,
},
},
Builders: []*messages.AlertedBuilder{ab},
Reason: &messages.Reason{
Raw: reason,
},
RegressionRanges: regressionRanges,
}
ret = append(ret, bf)
}
ret = filterHierarchicalSteps(ret)
return ret, nil
}
func generateAlertedBuilder(ctx context.Context, r failureRow) *messages.AlertedBuilder {
gitBegin := r.CPRangeOutputBegin
if gitBegin == nil {
gitBegin = r.CPRangeInputBegin
}
gitEnd := r.CPRangeOutputEnd
if gitEnd == nil {
gitEnd = r.CPRangeInputEnd
}
var latestPassingRev, firstFailingRev *messages.RevisionSummary
if gitBegin != nil {
latestPassingRev = &messages.RevisionSummary{
Position: int(gitBegin.Position.Int64),
Branch: gitBegin.Ref.StringVal,
Host: gitBegin.Host.StringVal,
Repo: gitBegin.Project.StringVal,
GitHash: gitBegin.ID.StringVal,
}
}
if gitEnd != nil {
firstFailingRev = &messages.RevisionSummary{
Position: int(gitEnd.Position.Int64),
Branch: gitEnd.Ref.StringVal,
Host: gitEnd.Host.StringVal,
Repo: gitEnd.Project.StringVal,
GitHash: gitEnd.ID.StringVal,
}
}
return &messages.AlertedBuilder{
Project: r.Project,
Bucket: r.Bucket,
Name: r.Builder,
BuilderGroup: r.BuilderGroup.StringVal,
FirstFailure: r.BuildIDBegin.Int64,
LatestFailure: r.BuildIDEnd.Int64,
FirstFailureBuildNumber: r.BuildNumberBegin.Int64,
LatestFailureBuildNumber: r.BuildNumberEnd.Int64,
URL: generateBuilderURL(r.Project, r.Bucket, r.Builder),
FirstFailureURL: generateBuildURL(r.Project, r.Bucket, r.Builder, r.BuildIDBegin),
LatestFailureURL: generateBuildURL(r.Project, r.Bucket, r.Builder, r.BuildIDEnd),
LatestPassingRev: latestPassingRev,
FirstFailingRev: firstFailingRev,
NumFailingTests: r.NumTests.Int64,
BuildStatus: r.BuildStatus,
}
}
func getRegressionRanges(earliestRev, latestRev *messages.RevisionSummary) []*messages.RegressionRange {
regressionRanges := []*messages.RegressionRange{}
if latestRev != nil && earliestRev != nil {
regRange := &messages.RegressionRange{
Repo: earliestRev.Repo,
Host: earliestRev.Host,
}
if earliestRev.GitHash != "" && latestRev.GitHash != "" {
regRange.Revisions = []string{earliestRev.GitHash, latestRev.GitHash}
}
if earliestRev.Position != 0 && latestRev.Position != 0 {
regRange.Positions = []string{
fmt.Sprintf("%s@{#%d}", earliestRev.Branch, earliestRev.Position),
fmt.Sprintf("%s@{#%d}", latestRev.Branch, latestRev.Position),
}
}
regressionRanges = append(regressionRanges, regRange)
}
return regressionRanges
}
func builderKey(b *messages.AlertedBuilder) string {
return fmt.Sprintf("%s/%s/%s", b.Project, b.Bucket, b.Name)
}
func filterHierarchicalSteps(failures []*messages.BuildFailure) []*messages.BuildFailure {
ret := []*messages.BuildFailure{}
// First group failures by builder.
failuresByBuilder := map[string][]*messages.BuildFailure{}
builders := map[string]*messages.AlertedBuilder{}
for _, f := range failures {
for _, b := range f.Builders {
key := builderKey(b)
builders[key] = b
if _, ok := failuresByBuilder[key]; !ok {
failuresByBuilder[key] = []*messages.BuildFailure{}
}
failuresByBuilder[key] = append(failuresByBuilder[key], f)
}
}
filteredFailuresByBuilder := map[string]stringset.Set{}
// For each builder, sort failing steps.
for key, failures := range failuresByBuilder {
sort.Sort(byStepName(failures))
filteredFailures := stringset.New(0)
// For each step in builder steps, if it's a prefix of the one after it,
// ignore that step.
for i, step := range failures {
if i <= len(failures)-2 {
nextStep := failures[i+1]
if strings.HasPrefix(nextStep.StepAtFault.Step.Name, step.StepAtFault.Step.Name+"|") {
// Skip this step since it has at least one child.
continue
}
}
filteredFailures.Add(step.StepAtFault.Step.Name)
}
filteredFailuresByBuilder[key] = filteredFailures
}
// Now filter out BuildFailures whose StepAtFault has been filtered out for
// that builder.
for _, failure := range failures {
filteredBuilders := []*messages.AlertedBuilder{}
for _, b := range failure.Builders {
key := builderKey(b)
filtered := filteredFailuresByBuilder[key]
if filtered.Has(failure.StepAtFault.Step.Name) {
filteredBuilders = append(filteredBuilders, b)
}
}
if len(filteredBuilders) > 0 {
failure.Builders = filteredBuilders
ret = append(ret, failure)
}
}
return ret
}
// TODO(seanmccullough): rename if we aren't sorting by step name, which may
// not be the most robust sorting method. Check if Step.Number is always
// populated, though that may not translate well because multiple builders are
// grouped by failing step and the same "step" may occur at different
// indexes in different builders.
type byStepName []*messages.BuildFailure
func (a byStepName) Len() int { return len(a) }
func (a byStepName) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a byStepName) Less(i, j int) bool {
return a[i].StepAtFault.Step.Name < a[j].StepAtFault.Step.Name
}
func sliceContains(haystack []string, needle string) bool {
for _, item := range haystack {
if needle == item {
return true
}
}
return false
}
func storeFailureRowsToMemcache(ctx context.Context, failureRows []failureRow, project string) error {
data, err := json.Marshal(failureRows)
if err != nil {
return err
}
val, err := zipData(data)
if err != nil {
return err
}
item := memcache.NewItem(ctx, fmt.Sprintf(bqMemcacheFormat, project)).SetValue(val)
return memcache.Set(ctx, item)
}
func retrieveFailureRowsFromMemcache(ctx context.Context, project string) ([]failureRow, error) {
failureRows := []failureRow{}
// Read from memcache
key := fmt.Sprintf(bqMemcacheFormat, project)
item, err := memcache.GetKey(ctx, key)
if err != nil {
return nil, err
}
// Decompress using zlib.
val, err := unzipData(item.Value())
if err != nil {
return nil, err
}
// Convert from JSON.
if err := json.Unmarshal(val, &failureRows); err != nil {
return nil, err
}
return failureRows, nil
}
func zipData(data []byte) ([]byte, error) {
b := bytes.Buffer{}
w := zlib.NewWriter(&b)
if _, err := w.Write(data); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return b.Bytes(), nil
}
func unzipData(data []byte) ([]byte, error) {
r, err := zlib.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
var unzippedData []byte
if unzippedData, err = ioutil.ReadAll(r); err != nil {
return nil, err
}
if err := r.Close(); err != nil {
return nil, err
}
return unzippedData, nil
}
func getBucketList(c context.Context, failureRows []failureRow) []*bqBucket {
seen := map[bqBucket]bool{}
result := []*bqBucket{}
for _, r := range failureRows {
b := bqBucket{
Project: r.Project,
Bucket: r.Bucket,
}
if _, ok := seen[b]; !ok {
seen[b] = true
result = append(result, &b)
}
}
return result
}
func getAvailableBuilders(c context.Context, cl client.BBBuildersClient, buckets []*bqBucket) ([]*buildbucketpb.BuilderItem, error) {
result := []*buildbucketpb.BuilderItem{}
for _, b := range buckets {
builders, err := client.ListBuildersByBucket(c, cl, b.Project, b.Bucket)
if err != nil {
// Bucket not found, maybe it is deleted?
if grpcutil.Code(err) == codes.NotFound {
logging.Infof(c, "Bucket %v not found: %v", b, err)
continue
}
return nil, err
}
result = append(result, builders...)
}
return result, nil
}
func filterDeletedBuildersWithClient(c context.Context, cl client.BBBuildersClient, failureRows []failureRow) ([]failureRow, error) {
buckets := getBucketList(c, failureRows)
builders, err := getAvailableBuilders(c, cl, buckets)
if err != nil {
return nil, err
}
logging.Infof(c, "There are %d available builders", len(builders))
builderMap := map[bqBuilder]bool{}
for _, b := range builders {
bqb := bqBuilder{
Project: b.Id.Project,
Bucket: b.Id.Bucket,
Builder: b.Id.Builder,
}
builderMap[bqb] = true
}
result := []failureRow{}
for i, row := range failureRows {
bqb := bqBuilder{
Project: row.Project,
Bucket: row.Bucket,
Builder: row.Builder,
}
if _, ok := builderMap[bqb]; ok {
result = append(result, failureRows[i])
} else {
logging.Debugf(c, "Builder %+v has been deleted", bqb)
}
}
logging.Infof(c, "Before filtering deleted builders, there are %d failures", len(failureRows))
logging.Infof(c, "After filtering deleted builders, there are %d failures", len(result))
return result, nil
}
func filterDeletedBuilders(c context.Context, failureRows []failureRow) ([]failureRow, error) {
cl, err := client.BuildersClient(c)
if err != nil {
return nil, err
}
return filterDeletedBuildersWithClient(c, cl, failureRows)
}