Skip to content

Commit 8d59e02

Browse files
dmitshurgopherbot
authored andcommitted
cmd/coordinator/internal/lucipoll: create a Go LUCI dashboard poller
Fetching builds (and builders) via the BuildBucket API isn't as quick and inexpensive as from Datastore, so it's necessary to add a caching layer. Use a simple polling approach to facilitate the migration to LUCI and make it possible to show LUCI build results on the current dashboard. There are various ways it can be improved, but all polling options are worse than proactively pushing test results. We'll evaluate what the long term future needs are later, so for now avoid investing too much. For golang/go#65913. Change-Id: Ib131a90b1108036b1d9618ea706959cbda2a0eac Reviewed-on: https://go-review.googlesource.com/c/build/+/567575 LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Michael Knyszek <[email protected]> Auto-Submit: Dmitri Shuralyov <[email protected]> Reviewed-by: Dmitri Shuralyov <[email protected]>
1 parent efa540e commit 8d59e02

File tree

1 file changed

+303
-0
lines changed

1 file changed

+303
-0
lines changed
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
// Copyright 2024 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
// Package lucipoll implements a simple polling LUCI client
6+
// for the possibly-short-term needs of the build dashboard.
7+
package lucipoll
8+
9+
import (
10+
"context"
11+
"encoding/json"
12+
"fmt"
13+
"log"
14+
"runtime/debug"
15+
"slices"
16+
"strings"
17+
"sync"
18+
"time"
19+
20+
bbpb "go.chromium.org/luci/buildbucket/proto"
21+
"go.chromium.org/luci/grpc/prpc"
22+
"golang.org/x/build/maintner/maintnerd/apipb"
23+
"golang.org/x/build/repos"
24+
"google.golang.org/grpc"
25+
"google.golang.org/protobuf/types/known/fieldmaskpb"
26+
)
27+
28+
// maintnerClient is a subset of apipb.MaintnerServiceClient.
29+
type maintnerClient interface {
30+
// GetDashboard is extracted from apipb.MaintnerServiceClient.
31+
GetDashboard(ctx context.Context, in *apipb.DashboardRequest, opts ...grpc.CallOption) (*apipb.DashboardResponse, error)
32+
}
33+
34+
type Builder struct {
35+
Name string
36+
*BuilderConfigProperties
37+
}
38+
39+
type BuilderConfigProperties struct {
40+
Repo string `json:"project,omitempty"`
41+
GoBranch string `json:"go_branch,omitempty"`
42+
Target struct {
43+
GOOS string `json:"goos,omitempty"`
44+
GOARCH string `json:"goarch,omitempty"`
45+
} `json:"target"`
46+
KnownIssue int `json:"known_issue,omitempty"`
47+
}
48+
49+
type Build struct {
50+
ID int64
51+
BuilderName string
52+
Status bbpb.Status
53+
}
54+
55+
func NewService(maintCl maintnerClient) *service {
56+
const crBuildBucketHost = "cr-buildbucket.appspot.com"
57+
58+
s := &service{
59+
maintCl: maintCl,
60+
buildersCl: bbpb.NewBuildersPRPCClient(&prpc.Client{Host: crBuildBucketHost}),
61+
buildsCl: bbpb.NewBuildsPRPCClient(&prpc.Client{Host: crBuildBucketHost}),
62+
}
63+
go s.pollLoop()
64+
return s
65+
}
66+
67+
type service struct {
68+
maintCl maintnerClient
69+
70+
buildersCl bbpb.BuildersClient
71+
buildsCl bbpb.BuildsClient
72+
73+
mu sync.RWMutex
74+
cached Snapshot
75+
}
76+
77+
// A Snapshot is a consistent snapshot in time holding LUCI post-submit state.
78+
type Snapshot struct {
79+
Builders map[string]Builder // Map key is builder name.
80+
RepoCommitBuilds map[string]map[string]map[string]Build // Map keys are repo, commit ID, builder name.
81+
}
82+
83+
// PostSubmitSnapshot returns a cached snapshot.
84+
func (s *service) PostSubmitSnapshot() Snapshot {
85+
s.mu.RLock()
86+
defer s.mu.RUnlock()
87+
return s.cached
88+
}
89+
90+
func (s *service) pollLoop() {
91+
ticker := time.NewTicker(2 * time.Minute)
92+
for {
93+
builders, builds, err := runOnce(context.Background(), s.maintCl, s.buildersCl, s.buildsCl)
94+
if err != nil {
95+
log.Println("lucipoll:", err)
96+
// Sleep a bit and retry.
97+
time.Sleep(30 * time.Second)
98+
continue
99+
}
100+
s.mu.Lock()
101+
s.cached = Snapshot{builders, builds}
102+
s.mu.Unlock()
103+
<-ticker.C // Limit how often we're willing to poll.
104+
}
105+
}
106+
107+
func runOnce(
108+
ctx context.Context,
109+
maintCl maintnerClient, buildersCl bbpb.BuildersClient, buildsCl bbpb.BuildsClient,
110+
) (_ map[string]Builder, _ map[string]map[string]map[string]Build, err error) {
111+
defer func() {
112+
if e := recover(); e != nil {
113+
err = fmt.Errorf("internal panic: %v\n\n%s", e, debug.Stack())
114+
}
115+
}()
116+
117+
// Fetch all current completed LUCI builders.
118+
//
119+
// TODO: It would be possible to cache initially fetched builders and then fetch
120+
// additional individual builders when seeing a build referencing an unknown one.
121+
// But that would need to take into account that a builder may be intentionally
122+
// removed from the LUCI dashboard. It adds more complexity, so for now do the
123+
// simple thing and save caching as an optional enhancement.
124+
builderList, err := listBuilders(ctx, buildersCl)
125+
if err != nil {
126+
return nil, nil, err
127+
}
128+
var builders = make(map[string]Builder)
129+
for _, b := range builderList {
130+
if _, ok := builders[b.Name]; ok {
131+
return nil, nil, fmt.Errorf("duplicate builder name %q", b.Name)
132+
}
133+
if b.KnownIssue != 0 {
134+
// Skip LUCI builders with a known issue at this time.
135+
// This also means builds from these builders are skipped below as well.
136+
// Such builders&builds can be included when the callers deem it useful.
137+
continue
138+
}
139+
builders[b.Name] = b
140+
}
141+
142+
// Fetch LUCI builds for the builders, repositories, and their commits
143+
// that are deemed relevant to the callers of this package.
144+
//
145+
// TODO: It would be possible to cache the last GetDashboard response
146+
// and if didn't change since the last, only fetch new LUCI builds
147+
// since then. Similarly, builds that were earlier for commits that
148+
// still show up in the response can be reused instead of refetched.
149+
// Furthermore, builds can be sorted according to how complete/useful
150+
// they are. These known enhancements are left for later as needed.
151+
var builds = make(map[string]map[string]map[string]Build)
152+
dashResp, err := maintCl.GetDashboard(ctx, &apipb.DashboardRequest{MaxCommits: 30})
153+
if err != nil {
154+
return nil, nil, err
155+
}
156+
var used, total int
157+
t0 := time.Now()
158+
// Fetch builds for Go repo commits.
159+
for _, c := range dashResp.Commits {
160+
repo, commit := "go", c.Commit
161+
buildList, err := fetchBuildsForCommit(ctx, buildsCl, repo, commit, "id", "builder.builder", "status", "input.gitiles_commit", "input.properties")
162+
if err != nil {
163+
return nil, nil, err
164+
}
165+
total += len(buildList)
166+
for _, b := range buildList {
167+
builder, ok := builders[b.GetBuilder().GetBuilder()]
168+
if !ok {
169+
// A build that isn't associated with a current builder we're tracking.
170+
// It might've been removed, or has a known issue. Skip this build too.
171+
continue
172+
}
173+
c := b.GetInput().GetGitilesCommit()
174+
if c.Project != builder.Repo {
175+
// A build that was triggered from a different project than the builder is for.
176+
// If the build hasn't completed, the exact repo commit hasn't been chosen yet.
177+
// For now such builds are not represented in the simple model of this package,
178+
// so skip it.
179+
continue
180+
}
181+
if builds[builder.Repo] == nil {
182+
builds[builder.Repo] = make(map[string]map[string]Build)
183+
}
184+
if builds[builder.Repo][c.Id] == nil {
185+
builds[builder.Repo][c.Id] = make(map[string]Build)
186+
}
187+
builds[builder.Repo][c.Id][b.GetBuilder().GetBuilder()] = Build{
188+
ID: b.GetId(),
189+
BuilderName: b.GetBuilder().GetBuilder(),
190+
Status: b.GetStatus(),
191+
}
192+
used++
193+
}
194+
}
195+
// Fetch builds for a single commit in each golang.org/x repo.
196+
for _, rh := range dashResp.RepoHeads {
197+
if rh.GerritProject == "go" {
198+
continue
199+
}
200+
if r, ok := repos.ByGerritProject[rh.GerritProject]; !ok || !r.ShowOnDashboard() {
201+
// Not a golang.org/x repository that's marked visible on the dashboard.
202+
// Skip it.
203+
continue
204+
}
205+
repo, commit := rh.GerritProject, rh.Commit.Commit
206+
buildList, err := fetchBuildsForCommit(ctx, buildsCl, repo, commit, "id", "builder.builder", "status", "input.gitiles_commit", "input.properties")
207+
if err != nil {
208+
return nil, nil, err
209+
}
210+
total += len(buildList)
211+
for _, b := range buildList {
212+
builder, ok := builders[b.GetBuilder().GetBuilder()]
213+
if !ok {
214+
// A build that isn't associated with a current builder we're tracking.
215+
// It might've been removed, or has a known issue. Skip this build too.
216+
continue
217+
}
218+
c := b.GetInput().GetGitilesCommit()
219+
if c.Project != builder.Repo {
220+
// When fetching builds for commits in x/ repos, it's expected
221+
// that build repo will always match builder repo. This isn't
222+
// true for the main Go repo because it triggers builds for x/
223+
// repos. But x/ repo builds don't trigger builds elsewhere.
224+
return nil, nil, fmt.Errorf("internal error: build repo %q doesn't match builder repo %q", b.GetInput().GetGitilesCommit().GetProject(), builder.Repo)
225+
}
226+
if builds[builder.Repo] == nil {
227+
builds[builder.Repo] = make(map[string]map[string]Build)
228+
}
229+
if builds[builder.Repo][c.Id] == nil {
230+
builds[builder.Repo][c.Id] = make(map[string]Build)
231+
}
232+
builds[builder.Repo][c.Id][b.GetBuilder().GetBuilder()] = Build{
233+
ID: b.GetId(),
234+
BuilderName: b.GetBuilder().GetBuilder(),
235+
Status: b.GetStatus(),
236+
}
237+
used++
238+
}
239+
}
240+
fmt.Printf("runOnce: aggregate GetBuildsForCommit calls fetched %d builds in %v\n", total, time.Since(t0))
241+
fmt.Printf("runOnce: used %d of those %d builds\n", used, total)
242+
243+
return builders, builds, nil
244+
}
245+
246+
// listBuilders lists post-submit LUCI builders.
247+
func listBuilders(ctx context.Context, buildersCl bbpb.BuildersClient) (builders []Builder, _ error) {
248+
var pageToken string
249+
nextPage:
250+
resp, err := buildersCl.ListBuilders(ctx, &bbpb.ListBuildersRequest{
251+
Project: "golang", Bucket: "ci",
252+
PageSize: 1000,
253+
PageToken: pageToken,
254+
})
255+
if err != nil {
256+
return nil, err
257+
}
258+
for _, b := range resp.GetBuilders() {
259+
var p BuilderConfigProperties
260+
if err := json.Unmarshal([]byte(b.GetConfig().GetProperties()), &p); err != nil {
261+
return nil, err
262+
}
263+
builders = append(builders, Builder{b.GetId().GetBuilder(), &p})
264+
}
265+
if resp.GetNextPageToken() != "" {
266+
pageToken = resp.GetNextPageToken()
267+
goto nextPage
268+
}
269+
slices.SortFunc(builders, func(a, b Builder) int {
270+
return strings.Compare(a.Name, b.Name)
271+
})
272+
return builders, nil
273+
}
274+
275+
// fetchBuildsForCommit fetches builds from all post-submit LUCI builders for a specific commit.
276+
func fetchBuildsForCommit(ctx context.Context, buildsCl bbpb.BuildsClient, repo, commit string, maskPaths ...string) (builds []*bbpb.Build, _ error) {
277+
mask, err := fieldmaskpb.New((*bbpb.Build)(nil), maskPaths...)
278+
if err != nil {
279+
return nil, err
280+
}
281+
var pageToken string
282+
nextPage:
283+
resp, err := buildsCl.SearchBuilds(ctx, &bbpb.SearchBuildsRequest{
284+
Predicate: &bbpb.BuildPredicate{
285+
Builder: &bbpb.BuilderID{Project: "golang", Bucket: "ci"},
286+
Tags: []*bbpb.StringPair{
287+
{Key: "buildset", Value: fmt.Sprintf("commit/gitiles/go.googlesource.com/%s/+/%s", repo, commit)},
288+
},
289+
},
290+
Mask: &bbpb.BuildMask{Fields: mask},
291+
PageSize: 1000,
292+
PageToken: pageToken,
293+
})
294+
if err != nil {
295+
return nil, err
296+
}
297+
builds = append(builds, resp.GetBuilds()...)
298+
if resp.GetNextPageToken() != "" {
299+
pageToken = resp.GetNextPageToken()
300+
goto nextPage
301+
}
302+
return builds, nil
303+
}

0 commit comments

Comments
 (0)