go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/clustering/runs/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 runs
    16  
    17  import (
    18  	"context"
    19  	"time"
    20  
    21  	"cloud.google.com/go/spanner"
    22  
    23  	"go.chromium.org/luci/common/errors"
    24  	"go.chromium.org/luci/server/span"
    25  
    26  	"go.chromium.org/luci/analysis/internal/clustering/rules"
    27  	"go.chromium.org/luci/analysis/internal/clustering/shards"
    28  	"go.chromium.org/luci/analysis/internal/config"
    29  	spanutil "go.chromium.org/luci/analysis/internal/span"
    30  	"go.chromium.org/luci/analysis/pbutil"
    31  )
    32  
    33  // ReclusteringRun contains the details of a runs used to re-cluster
    34  // test results.
    35  type ReclusteringRun struct {
    36  	// The LUCI Project for which this rule is defined.
    37  	Project string
    38  	// The attempt. This is the time the orchestrator run ends.
    39  	AttemptTimestamp time.Time
    40  	// The minimum algorithms version this reclustering run is trying
    41  	// to achieve. Chunks with an AlgorithmsVersion less than this
    42  	// value are eligible to be re-clustered.
    43  	AlgorithmsVersion int64
    44  	// The minimum config version the reclustering run is trying to achieve.
    45  	// Chunks with a ConfigVersion less than this value are eligible to be
    46  	// re-clustered.
    47  	ConfigVersion time.Time
    48  	// The minimum rules version the reclustering run is trying to achieve.
    49  	// Chunks with a RulesVersion less than this value are eligible to be
    50  	// re-clustered.
    51  	RulesVersion time.Time
    52  	// The number of shards created for this run (for this LUCI project).
    53  	ShardCount int64
    54  	// The number of shards that have reported progress (at least once).
    55  	// When this is equal to ShardCount, readers can have confidence Progress
    56  	// is a reasonable reflection of the progress made reclustering
    57  	// this project. Until then, it is a loose lower-bound.
    58  	ShardsReported int64
    59  	// The progress. This is a value between 0 and 1000*ShardCount.
    60  	Progress int64
    61  }
    62  
    63  // NotFound is the error returned by Read if the row could not be found.
    64  var NotFound = errors.New("reclustering run row not found")
    65  
    66  // StartingEpoch is the earliest valid run attempt time.
    67  var StartingEpoch = shards.StartingEpoch
    68  
    69  // MaxAttemptTimestamp can be passed to any Read....() method to
    70  // return data up to the last attempt.
    71  var MaxAttemptTimestamp = time.Date(9999, 12, 31, 23, 59, 0, 0, time.UTC)
    72  
    73  // Read reads the run with the given attempt timestamp in the given LUCI
    74  // project. If the row does not exist, the error NotFound is returned.
    75  func Read(ctx context.Context, projectID string, attemptTimestamp time.Time) (*ReclusteringRun, error) {
    76  	whereClause := `AttemptTimestamp = @attemptTimestamp`
    77  	params := map[string]any{
    78  		"attemptTimestamp": attemptTimestamp,
    79  	}
    80  	r, err := readLastWhere(ctx, projectID, whereClause, params)
    81  	if err != nil {
    82  		return nil, errors.Annotate(err, "query run").Err()
    83  	}
    84  	if r == nil {
    85  		return nil, NotFound
    86  	}
    87  	return r, nil
    88  }
    89  
    90  // ReadLastUpTo reads the last run in the given LUCI project up to
    91  // the given attempt timestamp. If no row exists,
    92  // a fake run is returned with the following details:
    93  // - Project matching the requested Project ID.
    94  // - AttemptTimestamp of StartingEpoch.
    95  // - AlgorithmsVersion of 1.
    96  // - ConfigVersion of clusteringcfg.StartingEpoch.
    97  // - RulesVersion of rules.StartingEpoch.
    98  // - ShardCount and ShardsReported of 1.
    99  // - Progress of 1000.
   100  func ReadLastUpTo(ctx context.Context, projectID string, upToAttemptTimestamp time.Time) (*ReclusteringRun, error) {
   101  	whereClause := `AttemptTimestamp <= @upToAttemptTimestamp`
   102  	params := map[string]any{
   103  		"upToAttemptTimestamp": upToAttemptTimestamp,
   104  	}
   105  	r, err := readLastWhere(ctx, projectID, whereClause, params)
   106  	if err != nil {
   107  		return nil, errors.Annotate(err, "query last run").Err()
   108  	}
   109  	if r == nil {
   110  		r = fakeLastRow(projectID)
   111  	}
   112  	return r, nil
   113  }
   114  
   115  // ReadLastWithProgress reads the last run with progress in the given LUCI
   116  // project up to the given attempt timestamp.
   117  //
   118  // If no row exists, a fake row is returned; see ReadLast for details.
   119  func ReadLastWithProgressUpTo(ctx context.Context, projectID string, upToAttemptTimestamp time.Time) (*ReclusteringRun, error) {
   120  	whereClause := `ShardsReported = ShardCount AND AttemptTimestamp <= @upToAttemptTimestamp`
   121  	params := map[string]any{
   122  		"upToAttemptTimestamp": upToAttemptTimestamp,
   123  	}
   124  	r, err := readLastWhere(ctx, projectID, whereClause, params)
   125  	if err != nil {
   126  		return nil, errors.Annotate(err, "query last run with progress up to").Err()
   127  	}
   128  	if r == nil {
   129  		r = fakeLastRow(projectID)
   130  	}
   131  	return r, nil
   132  }
   133  
   134  // ReadLastCompleteUpTo reads the last run that completed in the given LUCI
   135  // project up to the given attempt timestamp.
   136  // If no row exists, a fake row is returned; see ReadLast for details.
   137  func ReadLastCompleteUpTo(ctx context.Context, projectID string, upToAttemptTimestamp time.Time) (*ReclusteringRun, error) {
   138  	whereClause := `Progress = (ShardCount * 1000) AND AttemptTimestamp <= @upToAttemptTimestamp`
   139  	params := map[string]any{
   140  		"upToAttemptTimestamp": upToAttemptTimestamp,
   141  	}
   142  	r, err := readLastWhere(ctx, projectID, whereClause, params)
   143  	if err != nil {
   144  		return nil, errors.Annotate(err, "query last run up to").Err()
   145  	}
   146  	if r == nil {
   147  		r = fakeLastRow(projectID)
   148  	}
   149  	return r, nil
   150  }
   151  
   152  func fakeLastRow(projectID string) *ReclusteringRun {
   153  	return &ReclusteringRun{
   154  		Project:           projectID,
   155  		AttemptTimestamp:  StartingEpoch,
   156  		AlgorithmsVersion: 1,
   157  		ConfigVersion:     config.StartingEpoch,
   158  		RulesVersion:      rules.StartingEpoch,
   159  		ShardCount:        1,
   160  		ShardsReported:    1,
   161  		Progress:          1000,
   162  	}
   163  }
   164  
   165  // readLastWhere reads the last run matching the given where clause,
   166  // substituting params for any SQL parameters used in that clause.
   167  func readLastWhere(ctx context.Context, projectID string, whereClause string, params map[string]any) (*ReclusteringRun, error) {
   168  	stmt := spanner.NewStatement(`
   169  		SELECT
   170  		  AttemptTimestamp, ConfigVersion, RulesVersion,
   171  		  AlgorithmsVersion, ShardCount, ShardsReported, Progress
   172  		FROM ReclusteringRuns
   173  		WHERE Project = @projectID AND (` + whereClause + `)
   174  		ORDER BY AttemptTimestamp DESC
   175  		LIMIT 1
   176  	`)
   177  	for k, v := range params {
   178  		stmt.Params[k] = v
   179  	}
   180  	stmt.Params["projectID"] = projectID
   181  
   182  	it := span.Query(ctx, stmt)
   183  	rs := []*ReclusteringRun{}
   184  	err := it.Do(func(r *spanner.Row) error {
   185  		var attemptTimestamp, rulesVersion, configVersion time.Time
   186  		var algorithmsVersion, shardCount, shardsReported, progress int64
   187  		err := r.Columns(
   188  			&attemptTimestamp, &configVersion, &rulesVersion,
   189  			&algorithmsVersion, &shardCount, &shardsReported, &progress,
   190  		)
   191  		if err != nil {
   192  			return errors.Annotate(err, "read run row").Err()
   193  		}
   194  
   195  		run := &ReclusteringRun{
   196  			Project:           projectID,
   197  			AttemptTimestamp:  attemptTimestamp,
   198  			AlgorithmsVersion: algorithmsVersion,
   199  			ConfigVersion:     configVersion,
   200  			RulesVersion:      rulesVersion,
   201  			ShardCount:        shardCount,
   202  			ShardsReported:    shardsReported,
   203  			Progress:          progress,
   204  		}
   205  		rs = append(rs, run)
   206  		return nil
   207  	})
   208  	if len(rs) > 0 {
   209  		return rs[0], err
   210  	}
   211  	return nil, err
   212  }
   213  
   214  // Create inserts a new reclustering run.
   215  func Create(ctx context.Context, r *ReclusteringRun) error {
   216  	if err := validateRun(r); err != nil {
   217  		return err
   218  	}
   219  	ms := spanutil.InsertMap("ReclusteringRuns", map[string]any{
   220  		"Project":           r.Project,
   221  		"AttemptTimestamp":  r.AttemptTimestamp,
   222  		"AlgorithmsVersion": r.AlgorithmsVersion,
   223  		"ConfigVersion":     r.ConfigVersion,
   224  		"RulesVersion":      r.RulesVersion,
   225  		"ShardCount":        r.ShardCount,
   226  		"ShardsReported":    r.ShardsReported,
   227  		"Progress":          r.Progress,
   228  	})
   229  	span.BufferWrite(ctx, ms)
   230  	return nil
   231  }
   232  
   233  func validateRun(r *ReclusteringRun) error {
   234  	if err := pbutil.ValidateProject(r.Project); err != nil {
   235  		return errors.Annotate(err, "project").Err()
   236  	}
   237  	switch {
   238  	case r.AttemptTimestamp.Before(StartingEpoch):
   239  		return errors.New("attempt timestamp must be valid")
   240  	case r.AlgorithmsVersion <= 0:
   241  		return errors.New("algorithms version must be valid")
   242  	case r.ConfigVersion.Before(config.StartingEpoch):
   243  		return errors.New("config version must be valid")
   244  	case r.RulesVersion.Before(rules.StartingEpoch):
   245  		return errors.New("rules version must be valid")
   246  	case r.ShardCount <= 0:
   247  		return errors.New("shard count must be valid")
   248  	case r.ShardsReported < 0 || r.ShardsReported > r.ShardCount:
   249  		return errors.New("shards reported must be valid")
   250  	case r.Progress < 0 || r.Progress > (r.ShardCount*1000):
   251  		return errors.New("progress must be valid")
   252  	}
   253  	return nil
   254  }
   255  
   256  // UpdateProgress sets the progress of a particular run.
   257  func UpdateProgress(ctx context.Context, projectID string, attemptTimestamp time.Time, shardsReported, progress int64) error {
   258  	ms := spanutil.UpdateMap("ReclusteringRuns", map[string]any{
   259  		"Project":          projectID,
   260  		"AttemptTimestamp": attemptTimestamp,
   261  		"ShardsReported":   shardsReported,
   262  		"Progress":         progress,
   263  	})
   264  	span.BufferWrite(ctx, ms)
   265  	return nil
   266  }