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 }