go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/ingestion/control/span.go (about)

     1  // Copyright 2022 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package control
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"time"
    21  
    22  	"cloud.google.com/go/spanner"
    23  	"google.golang.org/protobuf/proto"
    24  
    25  	"go.chromium.org/luci/common/errors"
    26  	"go.chromium.org/luci/server/span"
    27  
    28  	ctlpb "go.chromium.org/luci/analysis/internal/ingestion/control/proto"
    29  	spanutil "go.chromium.org/luci/analysis/internal/span"
    30  	"go.chromium.org/luci/analysis/pbutil"
    31  	analysispb "go.chromium.org/luci/analysis/proto/v1"
    32  )
    33  
    34  // JoinStatsHours is the number of previous hours
    35  // ReadPresubmitRunJoinStatistics/ReadBuildJoinStatistics reads statistics for.
    36  const JoinStatsHours = 36
    37  
    38  // Entry is an ingestion control record, used to de-duplicate build ingestions
    39  // and synchronise them with presubmit results (if required).
    40  type Entry struct {
    41  	// The identity of the build which is being ingested.
    42  	// The scheme is: {buildbucket host name}/{build id}.
    43  	BuildID string
    44  
    45  	// Project is the LUCI Project the build belongs to. Used for
    46  	// metrics monitoring join performance.
    47  	BuildProject string
    48  
    49  	// BuildResult is the result of the build bucket build, to be passed
    50  	// to the result ingestion task. This is nil if the result is
    51  	// not yet known.
    52  	BuildResult *ctlpb.BuildResult
    53  
    54  	// BuildJoinedTime is the Spanner commit time the build result was
    55  	// populated. If the result has not yet been populated, this is the zero time.
    56  	BuildJoinedTime time.Time
    57  
    58  	// HasInvocation records wether the build has an associated (ResultDB)
    59  	// invocation.
    60  	// Value only populated once either BuildResult or InvocationResult populated.
    61  	HasInvocation bool
    62  
    63  	// Project is the LUCI Project the invocation belongs to. Used for
    64  	// metrics monitoring join performance.
    65  	InvocationProject string
    66  
    67  	// InvocationResult is the result of the invocation, to be passed
    68  	// to the result ingestion task. This is nil if the result is
    69  	// not yet known.
    70  	InvocationResult *ctlpb.InvocationResult
    71  
    72  	// InvocationJoinedTime is the Spanner commit time the invocation result
    73  	// was populated. If the result has not yet been populated, this is the zero time.
    74  	InvocationJoinedTime time.Time
    75  
    76  	// IsPresubmit records whether the build is part of a presubmit run.
    77  	// If true, ingestion should wait for the presubmit result to be
    78  	// populated (in addition to the build result) before commencing
    79  	// ingestion.
    80  	// Value only populated once either BuildResult or PresubmitResult populated.
    81  	IsPresubmit bool
    82  
    83  	// PresubmitProject is the LUCI Project the presubmit run belongs to.
    84  	// This may differ from the LUCI Project teh build belongs to. Used for
    85  	// metrics monitoring join performance.
    86  	PresubmitProject string
    87  
    88  	// PresubmitResult is result of the presubmit run, to be passed to the
    89  	// result ingestion task. This is nil if the result is not yet known.
    90  	PresubmitResult *ctlpb.PresubmitResult
    91  
    92  	// PresubmitJoinedTime is the Spanner commit time the presubmit result was
    93  	// populated. If the result has not yet been populated, this is the zero time.
    94  	PresubmitJoinedTime time.Time
    95  
    96  	// LastUpdated is the Spanner commit time the row was last updated.
    97  	LastUpdated time.Time
    98  
    99  	// The number of test result ingestion tasks have been created for this
   100  	// invocation.
   101  	// Used to avoid duplicate scheduling of ingestion tasks. If the page_index
   102  	// is the index of the page being processed, an ingestion task for the next
   103  	// page will only be created if (page_index + 1) == TaskCount.
   104  	TaskCount int64
   105  }
   106  
   107  // BuildID returns the control record key for a buildbucket build with the
   108  // given hostname and ID.
   109  func BuildID(hostname string, id int64) string {
   110  	return fmt.Sprintf("%s/%v", hostname, id)
   111  }
   112  
   113  // Read reads ingestion control records for the specified build IDs.
   114  // Exactly one *Entry is returned for each build ID. The result entry
   115  // at index i corresponds to the buildIDs[i].
   116  // If a record does not exist for the given build ID, an *Entry of
   117  // nil is returned for that build ID.
   118  func Read(ctx context.Context, buildIDs []string) ([]*Entry, error) {
   119  	uniqueIDs := make(map[string]struct{})
   120  	var keys []spanner.Key
   121  	for _, buildID := range buildIDs {
   122  		keys = append(keys, spanner.Key{buildID})
   123  		if _, ok := uniqueIDs[buildID]; ok {
   124  			return nil, fmt.Errorf("duplicate build ID %s", buildID)
   125  		}
   126  		uniqueIDs[buildID] = struct{}{}
   127  	}
   128  	cols := []string{
   129  		"BuildID",
   130  		"BuildProject",
   131  		"BuildResult",
   132  		"BuildJoinedTime",
   133  		"HasInvocation",
   134  		"InvocationProject",
   135  		"InvocationResult",
   136  		"InvocationJoinedTime",
   137  		"IsPresubmit",
   138  		"PresubmitProject",
   139  		"PresubmitResult",
   140  		"PresubmitJoinedTime",
   141  		"LastUpdated",
   142  		"TaskCount",
   143  	}
   144  	entryByBuildID := make(map[string]*Entry)
   145  	rows := span.Read(ctx, "Ingestions", spanner.KeySetFromKeys(keys...), cols)
   146  	f := func(r *spanner.Row) error {
   147  		var buildID string
   148  		var buildProject spanner.NullString
   149  		var buildResultBytes []byte
   150  		var buildJoinedTime spanner.NullTime
   151  		var hasInvocation spanner.NullBool
   152  		var invocationProject spanner.NullString
   153  		var invocationResultBytes []byte
   154  		var invocationJoinedTime spanner.NullTime
   155  		var isPresubmit spanner.NullBool
   156  		var presubmitProject spanner.NullString
   157  		var presubmitResultBytes []byte
   158  		var presubmitJoinedTime spanner.NullTime
   159  		var lastUpdated time.Time
   160  		var taskCount spanner.NullInt64
   161  
   162  		err := r.Columns(
   163  			&buildID,
   164  			&buildProject,
   165  			&buildResultBytes,
   166  			&buildJoinedTime,
   167  			&hasInvocation,
   168  			&invocationProject,
   169  			&invocationResultBytes,
   170  			&invocationJoinedTime,
   171  			&isPresubmit,
   172  			&presubmitProject,
   173  			&presubmitResultBytes,
   174  			&presubmitJoinedTime,
   175  			&lastUpdated,
   176  			&taskCount)
   177  		if err != nil {
   178  			return errors.Annotate(err, "read Ingestions row").Err()
   179  		}
   180  		var buildResult *ctlpb.BuildResult
   181  		if buildResultBytes != nil {
   182  			buildResult = &ctlpb.BuildResult{}
   183  			if err := proto.Unmarshal(buildResultBytes, buildResult); err != nil {
   184  				return errors.Annotate(err, "unmarshal build result").Err()
   185  			}
   186  		}
   187  		var invocationResult *ctlpb.InvocationResult
   188  		if invocationResultBytes != nil {
   189  			invocationResult = &ctlpb.InvocationResult{}
   190  			if err := proto.Unmarshal(invocationResultBytes, invocationResult); err != nil {
   191  				return errors.Annotate(err, "unmarshal invocation result").Err()
   192  			}
   193  		}
   194  		var presubmitResult *ctlpb.PresubmitResult
   195  		if presubmitResultBytes != nil {
   196  			presubmitResult = &ctlpb.PresubmitResult{}
   197  			if err := proto.Unmarshal(presubmitResultBytes, presubmitResult); err != nil {
   198  				return errors.Annotate(err, "unmarshal presubmit result").Err()
   199  			}
   200  		}
   201  
   202  		entryByBuildID[buildID] = &Entry{
   203  			BuildID:         buildID,
   204  			BuildProject:    buildProject.StringVal,
   205  			BuildResult:     buildResult,
   206  			BuildJoinedTime: buildJoinedTime.Time,
   207  			// HasInvocation uses NULL to indicate false.
   208  			HasInvocation:        hasInvocation.Valid && hasInvocation.Bool,
   209  			InvocationProject:    invocationProject.StringVal,
   210  			InvocationResult:     invocationResult,
   211  			InvocationJoinedTime: invocationJoinedTime.Time,
   212  			// IsPresubmit uses NULL to indicate false.
   213  			IsPresubmit:         isPresubmit.Valid && isPresubmit.Bool,
   214  			PresubmitProject:    presubmitProject.StringVal,
   215  			PresubmitResult:     presubmitResult,
   216  			PresubmitJoinedTime: presubmitJoinedTime.Time,
   217  			LastUpdated:         lastUpdated,
   218  			TaskCount:           taskCount.Int64,
   219  		}
   220  		return nil
   221  	}
   222  
   223  	if err := rows.Do(f); err != nil {
   224  		return nil, err
   225  	}
   226  
   227  	var result []*Entry
   228  	for _, buildID := range buildIDs {
   229  		// If the entry does not exist, return nil for that build ID.
   230  		entry := entryByBuildID[buildID]
   231  		result = append(result, entry)
   232  	}
   233  	return result, nil
   234  }
   235  
   236  // InsertOrUpdate creates or updates the given ingestion record.
   237  // This operation is not safe to perform blindly; perform only in a
   238  // read/write transaction with an attempted read of the corresponding entry.
   239  func InsertOrUpdate(ctx context.Context, e *Entry) error {
   240  	if err := validateEntry(e); err != nil {
   241  		return err
   242  	}
   243  	update := map[string]any{
   244  		"BuildId":              e.BuildID,
   245  		"BuildProject":         spanner.NullString{Valid: e.BuildProject != "", StringVal: e.BuildProject},
   246  		"BuildResult":          e.BuildResult,
   247  		"BuildJoinedTime":      spanner.NullTime{Valid: e.BuildJoinedTime != time.Time{}, Time: e.BuildJoinedTime},
   248  		"HasInvocation":        spanner.NullBool{Valid: e.HasInvocation, Bool: e.HasInvocation},
   249  		"InvocationProject":    spanner.NullString{Valid: e.InvocationProject != "", StringVal: e.InvocationProject},
   250  		"InvocationResult":     e.InvocationResult,
   251  		"InvocationJoinedTime": spanner.NullTime{Valid: e.InvocationJoinedTime != time.Time{}, Time: e.InvocationJoinedTime},
   252  		"IsPresubmit":          spanner.NullBool{Valid: e.IsPresubmit, Bool: e.IsPresubmit},
   253  		"PresubmitProject":     spanner.NullString{Valid: e.PresubmitProject != "", StringVal: e.PresubmitProject},
   254  		"PresubmitResult":      e.PresubmitResult,
   255  		"PresubmitJoinedTime":  spanner.NullTime{Valid: e.PresubmitJoinedTime != time.Time{}, Time: e.PresubmitJoinedTime},
   256  		"LastUpdated":          spanner.CommitTimestamp,
   257  		"TaskCount":            e.TaskCount,
   258  	}
   259  	m := spanutil.InsertOrUpdateMap("Ingestions", update)
   260  	span.BufferWrite(ctx, m)
   261  	return nil
   262  }
   263  
   264  // JoinStatistics captures indicators of how well two join inputs
   265  // (e.g. buildbucket build completions and presubmit run completions,
   266  // or buildbucket build completions and invocation finalizations)
   267  // are being joined.
   268  type JoinStatistics struct {
   269  	// TotalByHour captures the number of builds in the ingestions
   270  	// table eligible to be joined (i.e. have the left-hand join input).
   271  	//
   272  	// Data is broken down by by hours since the build became
   273  	// eligible for joining. Index 0 indicates the period
   274  	// from ]-1 hour, now], index 1 indicates [-2 hour, -1 hour] and so on.
   275  	TotalByHour []int64
   276  
   277  	// JoinedByHour captures the number of builds in the ingestions
   278  	// table eligible to be joined, which were successfully joined (have
   279  	// results for both join inputs present).
   280  	//
   281  	// Data is broken down by by hours since the build became
   282  	// eligible for joining. Index 0 indicates the period
   283  	// from ]-1 hour, now], index 1 indicates [-2 hour, -1 hour] and so on.
   284  	JoinedByHour []int64
   285  }
   286  
   287  // ReadBuildToPresubmitRunJoinStatistics measures the performance joining
   288  // builds to presubmit runs.
   289  //
   290  // The statistics returned uses completed builds with a presubmit run
   291  // as the denominator for measuring join performance.
   292  // The performance joining to presubmit run results is then measured.
   293  // Data is broken down by the project of the buildbucket build.
   294  // The last 36 hours of data for each project is returned. Hours are
   295  // measured since the buildbucket build result was received.
   296  func ReadBuildToPresubmitRunJoinStatistics(ctx context.Context) (map[string]JoinStatistics, error) {
   297  	stmt := spanner.NewStatement(`
   298  		SELECT
   299  		  BuildProject as project,
   300  		  TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), BuildJoinedTime, HOUR) as hour,
   301  		  COUNT(*) as total,
   302  		  COUNTIF(PresubmitResult IS NOT NULL) as joined,
   303  		FROM Ingestions
   304  		WHERE IsPresubmit
   305  		  AND BuildJoinedTime >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @hours HOUR)
   306  		GROUP BY project, hour
   307  	`)
   308  	stmt.Params["hours"] = JoinStatsHours
   309  	return readJoinStatistics(ctx, stmt)
   310  }
   311  
   312  // ReadPresubmitToBuildJoinStatistics measures the performance joining
   313  // presubmit runs to builds.
   314  //
   315  // The statistics returned uses builds as reported by completed
   316  // presubmit runs as the denominator for measuring join performance.
   317  // The performance joining to buildbucket build results is then measured.
   318  // Data is broken down by the project of the presubmit run.
   319  // The last 36 hours of data for each project is returned. Hours are
   320  // measured since the presubmit run result was received.
   321  func ReadPresubmitToBuildJoinStatistics(ctx context.Context) (map[string]JoinStatistics, error) {
   322  	stmt := spanner.NewStatement(`
   323  		SELECT
   324  		  PresubmitProject as project,
   325  		  TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), PresubmitJoinedTime, HOUR) as hour,
   326  		  COUNT(*) as total,
   327  		  COUNTIF(BuildResult IS NOT NULL) as joined,
   328  		FROM Ingestions
   329  		WHERE IsPresubmit
   330  		  AND PresubmitJoinedTime >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @hours HOUR)
   331  		GROUP BY project, hour
   332  	`)
   333  	stmt.Params["hours"] = JoinStatsHours
   334  	return readJoinStatistics(ctx, stmt)
   335  }
   336  
   337  // ReadBuildToInvocationJoinStatistics measures the performance joining
   338  // builds to finalized invocations.
   339  //
   340  // The statistics returned uses completed builds with an invocation
   341  // as the denominator for measuring join performance.
   342  // The performance joining to finalized invocations is then measured.
   343  // Data is broken down by the project of the buildbucket build.
   344  // The last 36 hours of data for each project is returned. Hours are
   345  // measured since the buildbucket build result was received.
   346  func ReadBuildToInvocationJoinStatistics(ctx context.Context) (map[string]JoinStatistics, error) {
   347  	stmt := spanner.NewStatement(`
   348  		SELECT
   349  		  BuildProject as project,
   350  		  TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), BuildJoinedTime, HOUR) as hour,
   351  		  COUNT(*) as total,
   352  		  COUNTIF(InvocationResult IS NOT NULL) as joined,
   353  		FROM Ingestions
   354  		WHERE HasInvocation
   355  		  AND BuildJoinedTime >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @hours HOUR)
   356  		GROUP BY project, hour
   357  	`)
   358  	stmt.Params["hours"] = JoinStatsHours
   359  	return readJoinStatistics(ctx, stmt)
   360  }
   361  
   362  // ReadInvocationToBuildJoinStatistics measures the performance joining
   363  // finalized invocations to builds.
   364  //
   365  // The statistics returned uses finalized invocations (for buildbucket builds)
   366  // as the denominator for measuring join performance.
   367  // The performance joining to buildbucket build results is then measured.
   368  // Data is broken down by the project of the ingested invocation (this
   369  // should be the same as the ingested build, although it comes from a
   370  // different source).
   371  // The last 36 hours of data for each project is returned. Hours are
   372  // measured since the finalized invocation was received.
   373  func ReadInvocationToBuildJoinStatistics(ctx context.Context) (map[string]JoinStatistics, error) {
   374  	stmt := spanner.NewStatement(`
   375  		SELECT
   376  		  InvocationProject as project,
   377  		  TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), InvocationJoinedTime, HOUR) as hour,
   378  		  COUNT(*) as total,
   379  		  COUNTIF(BuildResult IS NOT NULL) as joined,
   380  		FROM Ingestions
   381  		WHERE HasInvocation
   382  		  AND InvocationJoinedTime >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @hours HOUR)
   383  		GROUP BY project, hour
   384  	`)
   385  	stmt.Params["hours"] = JoinStatsHours
   386  	return readJoinStatistics(ctx, stmt)
   387  }
   388  
   389  func readJoinStatistics(ctx context.Context, stmt spanner.Statement) (map[string]JoinStatistics, error) {
   390  	result := make(map[string]JoinStatistics)
   391  	it := span.Query(ctx, stmt)
   392  	err := it.Do(func(r *spanner.Row) error {
   393  		var project string
   394  		var hour int64
   395  		var total, joined int64
   396  
   397  		err := r.Columns(&project, &hour, &total, &joined)
   398  		if err != nil {
   399  			return errors.Annotate(err, "read row").Err()
   400  		}
   401  
   402  		stats, ok := result[project]
   403  		if !ok {
   404  			stats = JoinStatistics{
   405  				// Add zero data for all hours.
   406  				TotalByHour:  make([]int64, JoinStatsHours),
   407  				JoinedByHour: make([]int64, JoinStatsHours),
   408  			}
   409  		}
   410  		stats.TotalByHour[hour] = total
   411  		stats.JoinedByHour[hour] = joined
   412  
   413  		result[project] = stats
   414  		return nil
   415  	})
   416  	if err != nil {
   417  		return nil, errors.Annotate(err, "query presubmit join stats by project").Err()
   418  	}
   419  	return result, nil
   420  }
   421  
   422  func validateEntry(e *Entry) error {
   423  	if e.BuildID == "" {
   424  		return errors.New("build ID must be specified")
   425  	}
   426  
   427  	if e.BuildResult != nil {
   428  		if err := ValidateBuildResult(e.BuildResult); err != nil {
   429  			return errors.Annotate(err, "build result").Err()
   430  		}
   431  		if err := pbutil.ValidateProject(e.BuildProject); err != nil {
   432  			return errors.Annotate(err, "build project").Err()
   433  		}
   434  	} else {
   435  		if e.BuildProject != "" {
   436  			return errors.New("build project must only be specified" +
   437  				" if build result is specified")
   438  		}
   439  	}
   440  
   441  	if e.InvocationResult != nil {
   442  		if !e.HasInvocation {
   443  			return errors.New("invocation result must not be set unless HasInvocation is set")
   444  		}
   445  		if err := pbutil.ValidateProject(e.InvocationProject); err != nil {
   446  			return errors.Annotate(err, "invocation project").Err()
   447  		}
   448  	} else {
   449  		if e.InvocationProject != "" {
   450  			return errors.New("invocation project must only be specified" +
   451  				" if invocation result is specified")
   452  		}
   453  	}
   454  
   455  	if e.PresubmitResult != nil {
   456  		if !e.IsPresubmit {
   457  			return errors.New("presubmit result must not be set unless IsPresubmit is set")
   458  		}
   459  		if err := ValidatePresubmitResult(e.PresubmitResult); err != nil {
   460  			return errors.Annotate(err, "presubmit result").Err()
   461  		}
   462  		if err := pbutil.ValidateProject(e.PresubmitProject); err != nil {
   463  			return errors.Annotate(err, "presubmit project").Err()
   464  		}
   465  	} else {
   466  		if e.PresubmitProject != "" {
   467  			return errors.New("presubmit project must only be specified" +
   468  				" if presubmit result is specified")
   469  		}
   470  	}
   471  
   472  	if e.TaskCount < 0 {
   473  		return errors.New("task count must be non-negative")
   474  	}
   475  	return nil
   476  }
   477  
   478  func ValidateBuildResult(r *ctlpb.BuildResult) error {
   479  	switch {
   480  	case r.Host == "":
   481  		return errors.New("host must be specified")
   482  	case r.Id == 0:
   483  		return errors.New("id must be specified")
   484  	case !r.CreationTime.IsValid():
   485  		return errors.New("creation time must be specified")
   486  	case r.Project == "":
   487  		return errors.New("project must be specified")
   488  	case r.HasInvocation && r.ResultdbHost == "":
   489  		return errors.New("resultdb_host must be specified if has_invocation set")
   490  	case r.Builder == "":
   491  		return errors.New("builder must be specified")
   492  	case r.Status == analysispb.BuildStatus_BUILD_STATUS_UNSPECIFIED:
   493  		return errors.New("build status must be specified")
   494  	}
   495  	return nil
   496  }
   497  
   498  func ValidatePresubmitResult(r *ctlpb.PresubmitResult) error {
   499  	switch {
   500  	case r.PresubmitRunId == nil:
   501  		return errors.New("presubmit run ID must be specified")
   502  	case r.PresubmitRunId.System != "luci-cv":
   503  		// LUCI CV is currently the only supported system.
   504  		return errors.New("presubmit run system must be 'luci-cv'")
   505  	case r.PresubmitRunId.Id == "":
   506  		return errors.New("presubmit run system-specific ID must be specified")
   507  	case !r.CreationTime.IsValid():
   508  		return errors.New("creation time must be specified and valid")
   509  	case r.Status == analysispb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_UNSPECIFIED:
   510  		return errors.New("status must be specified")
   511  	case r.Mode == analysispb.PresubmitRunMode_PRESUBMIT_RUN_MODE_UNSPECIFIED:
   512  		return errors.New("mode must be specified")
   513  	}
   514  	return nil
   515  }