Skip to content

Commit f9780b4

Browse files
committed
feat(stovepipe): Add build analysis polling
1 parent 62a4cdb commit f9780b4

16 files changed

Lines changed: 693 additions & 21 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["buildanalysis.go"],
6+
importpath = "github.com/uber/submitqueue/stovepipe/controller/buildanalysis",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//platform/base/messagequeue:go_default_library",
10+
"//platform/consumer:go_default_library",
11+
"//platform/metrics:go_default_library",
12+
"//stovepipe/core/loader:go_default_library",
13+
"//stovepipe/core/messagequeue:go_default_library",
14+
"//stovepipe/entity:go_default_library",
15+
"//stovepipe/extension/buildissue:go_default_library",
16+
"//stovepipe/extension/storage:go_default_library",
17+
"@com_github_uber_go_tally//:go_default_library",
18+
"@org_uber_go_zap//:go_default_library",
19+
],
20+
)
21+
22+
go_test(
23+
name = "go_default_test",
24+
srcs = ["buildanalysis_test.go"],
25+
embed = [":go_default_library"],
26+
deps = [
27+
"//platform/base/messagequeue:go_default_library",
28+
"//platform/consumer/mock:go_default_library",
29+
"//platform/errs:go_default_library",
30+
"//platform/metrics:go_default_library",
31+
"//stovepipe/core/messagequeue:go_default_library",
32+
"//stovepipe/entity:go_default_library",
33+
"//stovepipe/extension/buildissue:go_default_library",
34+
"//stovepipe/extension/buildissue/mock:go_default_library",
35+
"//stovepipe/extension/storage:go_default_library",
36+
"//stovepipe/extension/storage/mock:go_default_library",
37+
"@com_github_stretchr_testify//assert:go_default_library",
38+
"@com_github_stretchr_testify//require:go_default_library",
39+
"@com_github_uber_go_tally//:go_default_library",
40+
"@org_uber_go_mock//gomock:go_default_library",
41+
"@org_uber_go_zap//:go_default_library",
42+
],
43+
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Build analysis
2+
3+
The controller reloads the queue-scoped Build, invokes the queue's issue detector, and persists each returned observation individually. An existing observation is successful progress, so repeated polls and retries after partial writes are safe. Detector results accompanying an error are discarded.
4+
5+
Every delivery reads stored build status before detection. Nonterminal builds hold the same delivery for 30 seconds after persistence; terminal builds receive a detection and persistence pass before the delivery completes. If a build becomes terminal during analysis, the nonterminal status sampled at the start forces another pass. Status polling and updates remain the responsibility of buildsignal.
6+
7+
This controller only accumulates historical observations. An empty detection result neither deletes earlier observations nor establishes recovery. Open-issue projection, lifecycle history, and resolution are outside its scope.
8+
9+
The message and controller are defined here without runtime activation: no producer publishes analysis messages and no service consumer is registered yet. Detector implementations and pipeline wiring are separate work; the existing record path is unchanged.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
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 buildanalysis accumulates issue observations while builds run.
16+
// Build status is read from storage; status polling remains separate.
17+
package buildanalysis
18+
19+
import (
20+
"context"
21+
"errors"
22+
"fmt"
23+
24+
"github.com/uber-go/tally"
25+
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
26+
"github.com/uber/submitqueue/platform/consumer"
27+
"github.com/uber/submitqueue/platform/metrics"
28+
"github.com/uber/submitqueue/stovepipe/core/loader"
29+
stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue"
30+
"github.com/uber/submitqueue/stovepipe/entity"
31+
"github.com/uber/submitqueue/stovepipe/extension/buildissue"
32+
"github.com/uber/submitqueue/stovepipe/extension/storage"
33+
"go.uber.org/zap"
34+
)
35+
36+
// PollDelayMs is the delay after a successful nonterminal analysis pass.
37+
const PollDelayMs int64 = 30000
38+
39+
// Controller persists build-scoped observations before holding or completing a delivery.
40+
// Repeated observations are harmless; a terminal build still receives a detection pass.
41+
type Controller struct {
42+
logger *zap.SugaredLogger
43+
metricsScope tally.Scope
44+
stores storage.Factory
45+
detectorFactory buildissue.DetectorFactory
46+
topicKey consumer.TopicKey
47+
consumerGroup string
48+
}
49+
50+
func NewController(
51+
logger *zap.SugaredLogger,
52+
scope tally.Scope,
53+
stores storage.Factory,
54+
detectorFactory buildissue.DetectorFactory,
55+
topicKey consumer.TopicKey,
56+
consumerGroup string,
57+
) *Controller {
58+
return &Controller{
59+
logger: logger.Named("buildanalysis_controller"),
60+
metricsScope: scope.SubScope("buildanalysis_controller"),
61+
stores: stores,
62+
detectorFactory: detectorFactory,
63+
topicKey: topicKey,
64+
consumerGroup: consumerGroup,
65+
}
66+
}
67+
68+
// Process holds only after all observations have been persisted. Errors leave
69+
// partial progress durable; redelivery retries safely through duplicate creates.
70+
func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (retErr error) {
71+
op := metrics.Begin(c.metricsScope, "process", metrics.LongLatencyBuckets, metrics.TagsFromContext(ctx)...)
72+
defer func() { op.Complete(retErr) }()
73+
74+
msg := delivery.Message()
75+
analysis := &stovepipemq.BuildAnalysis{}
76+
if err := stovepipemq.Unmarshal(msg.Payload, analysis); err != nil {
77+
return fmt.Errorf("failed to deserialize build analysis: %w", err)
78+
}
79+
if err := entityqueue.ValidatePayloadQueue(msg, analysis.GetQueueName()); err != nil {
80+
return fmt.Errorf("invalid message identity: %w", err)
81+
}
82+
if analysis.GetId() == "" {
83+
return fmt.Errorf("build analysis requires a build ID")
84+
}
85+
store, err := c.stores.For(storage.Config{QueueName: analysis.GetQueueName()})
86+
if err != nil {
87+
return fmt.Errorf("failed to resolve storage for queue %q: %w", analysis.GetQueueName(), err)
88+
}
89+
build, err := loader.ByID(ctx, analysis.GetId(), store.GetBuildStore().Get, "build")
90+
if err != nil {
91+
return err
92+
}
93+
detector, err := c.detectorFactory.For(buildissue.DetectorConfig{QueueName: analysis.GetQueueName()})
94+
if err != nil {
95+
return fmt.Errorf("failed to resolve issue detector for queue %q: %w", analysis.GetQueueName(), err)
96+
}
97+
issues, err := detector.Detect(ctx, entity.BuildID{ID: build.ID})
98+
if err != nil {
99+
return fmt.Errorf("failed to detect issues for build %q: %w", build.ID, err)
100+
}
101+
issueStore := store.GetBuildIssueStore()
102+
for _, issue := range issues {
103+
if err := issueStore.Create(ctx, build.ID, issue); err != nil && !errors.Is(err, storage.ErrAlreadyExists) {
104+
return fmt.Errorf("failed to persist build issue build=%q task=%q unit=%q: %w", build.ID, issue.TaskID, issue.UnitID, err)
105+
}
106+
}
107+
108+
// A nonterminal status read before detection requires another pass,
109+
// even if the build finishes while detection is running.
110+
if !build.Status.IsTerminal() {
111+
delivery.Hold(PollDelayMs)
112+
}
113+
c.logger.Debugw("build analysis pass complete",
114+
"queue", analysis.GetQueueName(),
115+
"build_id", build.ID,
116+
"status", string(build.Status),
117+
"observed_issue_count", len(issues),
118+
)
119+
return nil
120+
}
121+
122+
func (c *Controller) Name() string { return "buildanalysis" }
123+
124+
func (c *Controller) TopicKey() consumer.TopicKey { return c.topicKey }
125+
126+
func (c *Controller) ConsumerGroup() string { return c.consumerGroup }
127+
128+
var _ consumer.Controller = (*Controller)(nil)

0 commit comments

Comments
 (0)