go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/cv/internal/buildbucket/facade/search.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 bbfacade
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"sync"
    21  	"sync/atomic"
    22  
    23  	"google.golang.org/protobuf/proto"
    24  
    25  	bbpb "go.chromium.org/luci/buildbucket/proto"
    26  	bbutil "go.chromium.org/luci/buildbucket/protoutil"
    27  	"go.chromium.org/luci/common/data/stringset"
    28  	"go.chromium.org/luci/common/errors"
    29  	"go.chromium.org/luci/common/retry/transient"
    30  
    31  	"go.chromium.org/luci/cv/internal/buildbucket"
    32  	"go.chromium.org/luci/cv/internal/run"
    33  	"go.chromium.org/luci/cv/internal/tryjob"
    34  )
    35  
    36  // AcceptedAdditionalPropKeys are additional properties keys that, if present
    37  // in the requested properties of the build, indicate that LUCI CV should still
    38  // consider the build as reusable.
    39  //
    40  // LUCI CV checks requested properties rather than input properties because
    41  // LUCI CV only cares about whether the properties used by a build are
    42  // different from the pre-defined properties in Project Config (assuming change
    43  // in properties may result in change in build result). Requested properties
    44  // are properties provided in ScheduleBuild, which currently is the only way to
    45  // add/modify build properties. LUCI CV permits certain keys which are either
    46  // added by LUCI CV itself, or known to not change build behavior.
    47  var AcceptedAdditionalPropKeys = stringset.NewFromSlice(
    48  	"$recipe_engine/cq",
    49  	"$recipe_engine/cv", // future proof
    50  	"requester",
    51  )
    52  
    53  var searchBuildsMask *bbpb.BuildMask
    54  
    55  func init() {
    56  	searchBuildsMask = proto.Clone(defaultMask).(*bbpb.BuildMask)
    57  	if err := searchBuildsMask.Fields.Append((*bbpb.Build)(nil),
    58  		"builder",
    59  		"input.gerrit_changes",
    60  		"infra.buildbucket.requested_properties",
    61  	); err != nil {
    62  		panic(err)
    63  	}
    64  }
    65  
    66  // Search searches Buildbucket for builds that match all provided CLs and
    67  // any of the provided definitions.
    68  //
    69  // Also filters out builds that specify extra properties. See:
    70  // `AcceptedAdditionalPropKeys`.
    71  //
    72  // `cb` is invoked for each matching Tryjob converted from Buildbucket build
    73  // until `cb` returns false or all matching Tryjobs are exhausted or error
    74  // occurs. The Tryjob `cb` receives only populates following fields:
    75  //   - ExternalID
    76  //   - Definition
    77  //   - Status
    78  //   - Result
    79  //
    80  // Also, the Tryjobs are guaranteed to have decreasing build ID (in other
    81  // word, from newest to oldest) ONLY within the same host.
    82  // For example, for following matching builds:
    83  //   - host: A, build: 100, create time: now
    84  //   - host: A, build: 101, create time: now - 2min
    85  //   - host: B, build: 1000, create time: now - 1min
    86  //   - host: B, build: 1001, create time: now - 3min
    87  //
    88  // It is possible that `cb` is called in following orders:
    89  //   - host: B, build: 1000, create time: now - 1min
    90  //   - host: A, build: 100, create time: now
    91  //   - host: B, build: 1001, create time: now - 3min
    92  //   - host: A, build: 101, create time: now - 2min
    93  //
    94  // TODO(crbug/1369200): Fix the edge case that may cause Search failing to
    95  // return newer builds before older builds across different patchsets.
    96  // TODO(yiwzhang): ensure `cb` get called from newest to oldest builds across
    97  // all hosts.
    98  //
    99  // Uses the provided `luciProject` for authentication. If any of the given
   100  // definitions defines builder from other LUCI Project, this other LUCI Project
   101  // should grant bucket READ permission to the provided `luciProject`.
   102  // Otherwise, the builds won't show up in the search result.
   103  func (f *Facade) Search(ctx context.Context, cls []*run.RunCL, definitions []*tryjob.Definition, luciProject string, cb func(*tryjob.Tryjob) bool) error {
   104  	shouldStop, stop := makeStopFunction()
   105  	workers, err := f.makeSearchWorkers(ctx, cls, definitions, luciProject, shouldStop)
   106  	if err != nil {
   107  		return err
   108  	}
   109  
   110  	var wg sync.WaitGroup
   111  	wg.Add(len(workers))
   112  	resultCh := make(chan searchResult)
   113  	for _, worker := range workers {
   114  		worker := worker
   115  		go func() {
   116  			defer wg.Done()
   117  			worker.search(ctx, resultCh)
   118  		}()
   119  	}
   120  	go func() {
   121  		wg.Wait()
   122  		close(resultCh)
   123  	}()
   124  
   125  	for res := range resultCh {
   126  		switch {
   127  		case shouldStop(): // draining
   128  			continue
   129  		case res.err != nil:
   130  			err = res.err
   131  			stop()
   132  		case !cb(res.tryjob):
   133  			stop()
   134  		}
   135  	}
   136  	return err
   137  }
   138  
   139  func makeStopFunction() (shouldStop func() bool, stop func()) {
   140  	var stopIndicator int32
   141  	shouldStop = func() bool {
   142  		return atomic.LoadInt32(&stopIndicator) > 0
   143  	}
   144  	stop = func() {
   145  		atomic.AddInt32(&stopIndicator, 1)
   146  	}
   147  	return shouldStop, stop
   148  }
   149  
   150  // searchWorker is a worker search builds against a single Buildbucket host.
   151  //
   152  // The matching build will be pushed to `resultCh` one by one including any
   153  // error occurred during the search. `resultCh` is closed when either a error
   154  // is returned or all matching builds have been exhausted.
   155  //
   156  // Algorithm for searching:
   157  //   - Pick the CL with smallest (patchset - min_equivalent_patchset) as the
   158  //     search predicate
   159  //   - Page the search response and accept a build if
   160  //   - The Gerrit changes of the build matches the input CLs. Match means
   161  //     host and change number are the same and the patchset is in between
   162  //     cl.min_equivalent_patchset and cl.patchset
   163  //   - The builder of the build should be either the main builder or the
   164  //     equivalent builder of any of the input definitions
   165  //   - The requested properties only have keys specified in
   166  //     `AcceptedPropertyKeys`
   167  type searchWorker struct {
   168  	bbHost              string
   169  	luciProject         string
   170  	bbClient            buildbucket.Client
   171  	clSearchTarget      *run.RunCL
   172  	acceptedCLRanges    map[string]patchsetRange
   173  	builderToDefinition map[string]*tryjob.Definition
   174  	shouldStop          func() bool
   175  }
   176  
   177  type patchsetRange struct {
   178  	minIncl, maxIncl int64
   179  }
   180  
   181  func (f *Facade) makeSearchWorkers(ctx context.Context, cls []*run.RunCL, definitions []*tryjob.Definition, luciProject string, shouldStop func() bool) ([]searchWorker, error) {
   182  	var hostToWorker = make(map[string]searchWorker)
   183  	for _, def := range definitions {
   184  		if def.GetBuildbucket() == nil {
   185  			panic(fmt.Errorf("call buildbucket backend for non-buildbucket definition: %s", def))
   186  		}
   187  
   188  		bbHost := def.GetBuildbucket().GetHost()
   189  		worker, ok := hostToWorker[bbHost]
   190  		if !ok {
   191  			bbClient, err := f.ClientFactory.MakeClient(ctx, bbHost, luciProject)
   192  			if err != nil {
   193  				return nil, err
   194  			}
   195  			clRanges, clWithSmallestRange := computeCLRangesAndPickSmallest(cls)
   196  			worker = searchWorker{
   197  				bbHost:              bbHost,
   198  				luciProject:         luciProject,
   199  				bbClient:            bbClient,
   200  				acceptedCLRanges:    clRanges,
   201  				clSearchTarget:      clWithSmallestRange,
   202  				builderToDefinition: make(map[string]*tryjob.Definition),
   203  				shouldStop:          shouldStop,
   204  			}
   205  			hostToWorker[bbHost] = worker
   206  		}
   207  		worker.builderToDefinition[bbutil.FormatBuilderID(def.GetBuildbucket().GetBuilder())] = def
   208  		if def.GetEquivalentTo() != nil {
   209  			worker.builderToDefinition[bbutil.FormatBuilderID(def.GetEquivalentTo().GetBuildbucket().GetBuilder())] = def
   210  		}
   211  	}
   212  	ret := make([]searchWorker, 0, len(hostToWorker))
   213  	for _, worker := range hostToWorker {
   214  		ret = append(ret, worker)
   215  	}
   216  	return ret, nil
   217  }
   218  
   219  func computeCLRangesAndPickSmallest(cls []*run.RunCL) (map[string]patchsetRange, *run.RunCL) {
   220  	clToRange := make(map[string]patchsetRange, len(cls))
   221  	var clWithSmallestPatchsetRange *run.RunCL
   222  	var smallestRange int64
   223  	for _, cl := range cls {
   224  		psRange := struct {
   225  			minIncl int64
   226  			maxIncl int64
   227  		}{int64(cl.Detail.GetMinEquivalentPatchset()), int64(cl.Detail.GetPatchset())}
   228  		clToRange[formatChangeID(cl.Detail.GetGerrit().GetHost(), cl.Detail.GetGerrit().GetInfo().GetNumber())] = psRange
   229  		if r := psRange.maxIncl - psRange.minIncl + 1; smallestRange == 0 || r < smallestRange {
   230  			clWithSmallestPatchsetRange = cl
   231  			smallestRange = r
   232  		}
   233  	}
   234  	return clToRange, clWithSmallestPatchsetRange
   235  }
   236  
   237  type searchResult struct {
   238  	tryjob *tryjob.Tryjob
   239  	err    error
   240  }
   241  
   242  func (sw *searchWorker) search(ctx context.Context, resultCh chan<- searchResult) {
   243  	changeDetail := sw.clSearchTarget.Detail
   244  	gc := &bbpb.GerritChange{
   245  		Host:    changeDetail.GetGerrit().GetHost(),
   246  		Project: changeDetail.GetGerrit().GetInfo().GetProject(),
   247  		Change:  changeDetail.GetGerrit().GetInfo().GetNumber(),
   248  	}
   249  	req := &bbpb.SearchBuildsRequest{
   250  		Predicate: &bbpb.BuildPredicate{
   251  			GerritChanges:       []*bbpb.GerritChange{gc},
   252  			IncludeExperimental: true,
   253  		},
   254  		Mask: searchBuildsMask,
   255  	}
   256  	for ps := changeDetail.GetPatchset(); ps >= changeDetail.GetMinEquivalentPatchset(); ps-- {
   257  		gc.Patchset = int64(ps)
   258  		req.PageToken = ""
   259  		for {
   260  			if sw.shouldStop() {
   261  				return
   262  			}
   263  			res, err := sw.bbClient.SearchBuilds(ctx, req)
   264  			if err != nil {
   265  				resultCh <- searchResult{err: errors.Annotate(err, "failed to call buildbucket.SearchBuilds").Tag(transient.Tag).Err()}
   266  				return
   267  			}
   268  			for _, build := range res.GetBuilds() {
   269  				if def, ok := sw.canUseBuild(build); ok {
   270  					tj, err := sw.toTryjob(ctx, build, def)
   271  					if err != nil {
   272  						resultCh <- searchResult{err: err}
   273  						return
   274  					}
   275  					resultCh <- searchResult{tryjob: tj}
   276  				}
   277  			}
   278  			if res.NextPageToken == "" {
   279  				break
   280  			}
   281  			req.PageToken = res.NextPageToken
   282  		}
   283  	}
   284  }
   285  
   286  func (sw searchWorker) canUseBuild(build *bbpb.Build) (*tryjob.Definition, bool) {
   287  	switch def, matchBuilder := sw.builderToDefinition[bbutil.FormatBuilderID(build.GetBuilder())]; {
   288  	case !matchBuilder:
   289  	case !sw.matchCLs(build):
   290  	case hasAdditionalProperties(build):
   291  	default:
   292  		return def, true
   293  	}
   294  	return nil, false
   295  }
   296  
   297  func (sw searchWorker) matchCLs(build *bbpb.Build) bool {
   298  	gcs := build.GetInput().GetGerritChanges()
   299  	changeToPatchset := make(map[string]int64, len(gcs))
   300  	for _, gc := range gcs {
   301  		changeToPatchset[formatChangeID(gc.GetHost(), gc.GetChange())] = gc.GetPatchset()
   302  	}
   303  	if len(changeToPatchset) != len(sw.acceptedCLRanges) {
   304  		return false
   305  	}
   306  	for changeID, ps := range changeToPatchset {
   307  		switch psRange, ok := sw.acceptedCLRanges[changeID]; {
   308  		case !ok:
   309  			return false
   310  		case ps < psRange.minIncl:
   311  			return false
   312  		case ps > psRange.maxIncl:
   313  			return false
   314  		}
   315  	}
   316  	return true
   317  }
   318  
   319  func hasAdditionalProperties(build *bbpb.Build) bool {
   320  	props := build.GetInfra().GetBuildbucket().GetRequestedProperties().GetFields()
   321  	for key := range props {
   322  		if !AcceptedAdditionalPropKeys.Has(key) {
   323  			return true
   324  		}
   325  	}
   326  	return false
   327  }
   328  
   329  func formatChangeID(host string, changeNum int64) string {
   330  	return fmt.Sprintf("%s/%d", host, changeNum)
   331  }
   332  
   333  func (sw *searchWorker) toTryjob(ctx context.Context, build *bbpb.Build, def *tryjob.Definition) (*tryjob.Tryjob, error) {
   334  	status, result, err := parseStatusAndResult(ctx, build)
   335  	if err != nil {
   336  		return nil, err
   337  	}
   338  	return &tryjob.Tryjob{
   339  		ExternalID: tryjob.MustBuildbucketID(sw.bbHost, build.Id),
   340  		Definition: def,
   341  		Status:     status,
   342  		Result:     result,
   343  	}, nil
   344  }