go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/analysis/internal/clustering/reclustering/update.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 reclustering
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"time"
    21  
    22  	"go.opentelemetry.io/otel/attribute"
    23  
    24  	"go.chromium.org/luci/common/errors"
    25  	"go.chromium.org/luci/server/caching"
    26  
    27  	"go.chromium.org/luci/analysis/internal/clustering"
    28  	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
    29  	cpb "go.chromium.org/luci/analysis/internal/clustering/proto"
    30  	"go.chromium.org/luci/analysis/internal/clustering/rules/cache"
    31  	"go.chromium.org/luci/analysis/internal/clustering/state"
    32  	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
    33  	"go.chromium.org/luci/analysis/internal/tracing"
    34  )
    35  
    36  // TODO(crbug.com/1243174). Instrument the size of this cache so that we
    37  // can monitor it.
    38  var rulesCache = cache.NewRulesCache(caching.RegisterLRUCache[string, *cache.Ruleset](0))
    39  
    40  // Ruleset returns the cached ruleset for the given project. If a minimum
    41  // version of rule predicates is required, pass it as minimumPredicatesVersion.
    42  // If a strong read is required, pass cache.StrongRead.
    43  // Otherwise, pass rules.StartingEpoch.
    44  func Ruleset(ctx context.Context, project string, minimumPredicatesVersion time.Time) (*cache.Ruleset, error) {
    45  	ruleset, err := rulesCache.Ruleset(ctx, project, minimumPredicatesVersion)
    46  	if err != nil {
    47  		return nil, err
    48  	}
    49  	return ruleset, nil
    50  }
    51  
    52  // Analysis is the interface for cluster analysis.
    53  type Analysis interface {
    54  	// HandleUpdatedClusters handles (re-)clustered test results. It is called
    55  	// after the spanner transaction effecting the (re-)clustering has
    56  	// committed. commitTime is the Spanner time the transaction committed.
    57  	HandleUpdatedClusters(ctx context.Context, updates *clustering.Update, commitTime time.Time) error
    58  }
    59  
    60  // PendingUpdate is a (re-)clustering of a chunk of test results
    61  // that has not been applied to Spanner and/or sent for re-analysis
    62  // yet.
    63  type PendingUpdate struct {
    64  	// Chunk is the identity of the chunk which will be updated.
    65  	Chunk         state.ChunkKey
    66  	existingState *state.Entry
    67  	newClustering clustering.ClusterResults
    68  	updates       []*clustering.FailureUpdate
    69  }
    70  
    71  // PrepareUpdate will (re-)cluster the specific chunk of test results,
    72  // preparing an updated state for Spanner and updates to be exported
    73  // to analysis. The caller can determine how to batch these updates/
    74  // exports together, with help of the Size() method on the returned
    75  // pending update.
    76  //
    77  // If the chunk does not exist in Spanner, pass a *state.Entry
    78  // with project, chunkID, objectID and partitionTime set
    79  // but with LastUpdated set to its zero value. The chunk will be
    80  // clustered for the first time and saved to Spanner.
    81  //
    82  // If the chunk does exist in Spanner, pass the state.Entry read
    83  // from Spanner, along with the test results. The chunk will be
    84  // re-clustered and updated.
    85  func PrepareUpdate(ctx context.Context, ruleset *cache.Ruleset, config *compiledcfg.ProjectConfig, chunk *cpb.Chunk, existingState *state.Entry) (upd *PendingUpdate, err error) {
    86  	_, s := tracing.Start(ctx, "go.chromium.org/luci/analysis/internal/clustering/reclustering.PrepareUpdate",
    87  		attribute.String("project", existingState.Project),
    88  		attribute.String("chunkID", existingState.ChunkID),
    89  	)
    90  	defer func() { tracing.End(s, err) }()
    91  
    92  	exists := !existingState.LastUpdated.IsZero()
    93  	var existingClustering clustering.ClusterResults
    94  	if !exists {
    95  		existingClustering = algorithms.NewEmptyClusterResults(len(chunk.Failures))
    96  	} else {
    97  		if len(existingState.Clustering.Clusters) != len(chunk.Failures) {
    98  			return nil, fmt.Errorf("existing clustering does not match chunk; got clusters for %v test results, want %v", len(existingClustering.Clusters), len(chunk.Failures))
    99  		}
   100  		existingClustering = existingState.Clustering
   101  	}
   102  
   103  	newClustering := algorithms.Cluster(config, ruleset, existingClustering, clustering.FailuresFromProtos(chunk.Failures))
   104  
   105  	updates := prepareClusterUpdates(chunk, existingClustering, newClustering)
   106  
   107  	return &PendingUpdate{
   108  		Chunk:         state.ChunkKey{Project: existingState.Project, ChunkID: existingState.ChunkID},
   109  		existingState: existingState,
   110  		newClustering: newClustering,
   111  		updates:       updates,
   112  	}, nil
   113  }
   114  
   115  // Attempts to apply the update to Spanner.
   116  //
   117  // Important: Before calling this method, the caller should verify the chunks
   118  // in Spanner still have the same LastUpdatedTime as passed to PrepareUpdate,
   119  // in the same transaction as attempting this update.
   120  // This will prevent clobbering a concurrently applied update or create.
   121  //
   122  // In case of an update race, PrepareUpdate should be retried with a more
   123  // recent version of the chunk.
   124  func (p *PendingUpdate) ApplyToSpanner(ctx context.Context) error {
   125  	exists := !p.existingState.LastUpdated.IsZero()
   126  	if !exists {
   127  		clusterState := &state.Entry{
   128  			Project:       p.existingState.Project,
   129  			ChunkID:       p.existingState.ChunkID,
   130  			PartitionTime: p.existingState.PartitionTime,
   131  			ObjectID:      p.existingState.ObjectID,
   132  			Clustering:    p.newClustering,
   133  		}
   134  		if err := state.Create(ctx, clusterState); err != nil {
   135  			return err
   136  		}
   137  	} else {
   138  		if err := state.UpdateClustering(ctx, p.existingState, &p.newClustering); err != nil {
   139  			return err
   140  		}
   141  	}
   142  	return nil
   143  }
   144  
   145  // ApplyToAnalysis exports changed failures for re-analysis. The
   146  // Spanner commit time must be provided so that analysis has the
   147  // correct update chronology.
   148  func (p *PendingUpdate) ApplyToAnalysis(ctx context.Context, analysis Analysis, commitTime time.Time) error {
   149  	if len(p.updates) > 0 {
   150  		update := &clustering.Update{
   151  			Project: p.existingState.Project,
   152  			ChunkID: p.existingState.ChunkID,
   153  			Updates: p.updates,
   154  		}
   155  		if err := analysis.HandleUpdatedClusters(ctx, update, commitTime); err != nil {
   156  			return errors.Annotate(err, "handle updated clusters (project: %s chunkID: %s)", p.existingState.Project, p.existingState.ChunkID).Err()
   157  		}
   158  	}
   159  	return nil
   160  }
   161  
   162  // EstimatedTransactionSize returns the estimated size of the
   163  // Spanner transaction, in bytes.
   164  func (p *PendingUpdate) EstimatedTransactionSize() int {
   165  	if len(p.updates) > 0 {
   166  		// This means we will be updating the clustering state in Spanner,
   167  		// not just the Version fields.
   168  		numClusters := 0
   169  		for _, cs := range p.newClustering.Clusters {
   170  			numClusters += len(cs)
   171  		}
   172  		// Est. 10 bytes per cluster, plus 200 bytes overhead.
   173  		return 200 + numClusters*10
   174  	}
   175  	// The clustering state has not changed, only
   176  	// AlgorithmsVersion and RulesVersion will be updated.
   177  	return 200
   178  }
   179  
   180  // FailuresUpdated returns the number of failures that will
   181  // exported for re-analysis as a result of the update.
   182  func (p *PendingUpdate) FailuresUpdated() int {
   183  	return len(p.updates)
   184  }
   185  
   186  func prepareClusterUpdates(chunk *cpb.Chunk, previousClustering clustering.ClusterResults, newClustering clustering.ClusterResults) []*clustering.FailureUpdate {
   187  	var updates []*clustering.FailureUpdate
   188  	for i, testResult := range chunk.Failures {
   189  		previousClusters := previousClustering.Clusters[i]
   190  		newClusters := newClustering.Clusters[i]
   191  
   192  		if !clustering.ClustersEqual(previousClusters, newClusters) {
   193  			update := &clustering.FailureUpdate{
   194  				TestResult:       testResult,
   195  				PreviousClusters: previousClusters,
   196  				NewClusters:      newClusters,
   197  			}
   198  			updates = append(updates, update)
   199  		}
   200  	}
   201  	return updates
   202  }