Skip to content

Commit 4d3fb9d

Browse files
authored
feat(stovepipe): Add build analysis polling (#907)
## Summary **What**: - Add periodic issue detection and durable observation recording, with a final pass after observing build completion. - Stop polling validations that end without a recorded build result and use normal queue retry handling for analysis failures. **Why**: - Accumulate validation failures before build completion and retry partial persistence without duplicating observations. ## Test Plan - [x] Add unit tests. ## Revert Plan - Revert this PR. No runtime wiring or schema changes are introduced, and existing build processing is unchanged. ## Issue Links - [CODEM-607](https://linear.app/uber/issue/CODEM-607/build-analysis) ## Stack 1. #902 1. #907 1. #915 1. #909 1. #916
1 parent 2e3e679 commit 4d3fb9d

16 files changed

Lines changed: 924 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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Build analysis
2+
3+
## Observations
4+
5+
The controller reloads the queue-scoped Build, invokes the queue's issue detector, and persists each returned observation individually. Observations already stored for the same build, task, and unit within a queue are not persisted again, so repeated polls and retries after partial writes are safe. Detector results accompanying an error are discarded.
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+
## Polling and completion
10+
11+
Every delivery reloads the Build and its Request before detection. After a successful pass, a nonterminal Build holds the delivery for two minutes; a terminal Build completes the delivery after persistence. The status read before detection determines whether the pass holds: if the Build finishes during detection, another delivery is needed to analyze it after observing terminal status. More than one delivery may analyze the same terminal Build; observation persistence remains idempotent.
12+
13+
If the Request is terminal but the loaded Build is nonterminal, analysis reloads the Build before deciding to stop. The Build may have finished between the initial Build read and the Request read; reloading avoids skipping its final analysis. If it is still nonterminal, analysis stops without detection or a hold. This can occur when the status-polling DLQ handler fails the Request without learning the actual build result, leaving the Build at its last observed status.
14+
15+
Status polling and updates remain the responsibility of buildsignal. Analysis never changes Request state or releases build slots.
16+
17+
## Errors
18+
19+
Detection, persistence, configuration, state-loading, and message-validation errors are returned to the consumer for its normal retry or DLQ policy, whether the Build is running or terminal. Analysis errors do not use the two-minute polling hold. The consumer classifies errors and applies its configured retry limits and backoff; a final analysis can still fail and dead-letter.
20+
21+
The message and controller are defined without runtime producers or consumer registration. Host integration and detector implementations are separate work.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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 during and after builds.
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 in milliseconds after a successful nonterminal pass.
37+
// Two minutes limits provider traffic during long-running builds.
38+
const PollDelayMs int64 = 120000
39+
40+
// Controller accumulates observations while builds run and after completion.
41+
// Terminal analysis must finish persistence before its delivery completes.
42+
type Controller struct {
43+
logger *zap.SugaredLogger
44+
metricsScope tally.Scope
45+
stores storage.Factory
46+
detectorFactory buildissue.DetectorFactory
47+
topicKey consumer.TopicKey
48+
consumerGroup string
49+
}
50+
51+
func NewController(
52+
logger *zap.SugaredLogger,
53+
scope tally.Scope,
54+
stores storage.Factory,
55+
detectorFactory buildissue.DetectorFactory,
56+
topicKey consumer.TopicKey,
57+
consumerGroup string,
58+
) *Controller {
59+
return &Controller{
60+
logger: logger.Named("buildanalysis_controller"),
61+
metricsScope: scope.SubScope("buildanalysis_controller"),
62+
stores: stores,
63+
detectorFactory: detectorFactory,
64+
topicKey: topicKey,
65+
consumerGroup: consumerGroup,
66+
}
67+
}
68+
69+
// Process holds after a successful nonterminal pass. Errors are returned for
70+
// normal queue retry or DLQ handling; partial writes are safe to retry.
71+
func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (retErr error) {
72+
op := metrics.Begin(c.metricsScope, "process", metrics.LongLatencyBuckets, metrics.TagsFromContext(ctx)...)
73+
defer func() { op.Complete(retErr) }()
74+
75+
msg := delivery.Message()
76+
analysis := &stovepipemq.BuildAnalysis{}
77+
if err := stovepipemq.Unmarshal(msg.Payload, analysis); err != nil {
78+
return fmt.Errorf("failed to deserialize build analysis: %w", err)
79+
}
80+
81+
if err := entityqueue.ValidatePayloadQueue(msg, analysis.GetQueueName()); err != nil {
82+
return fmt.Errorf("invalid message identity: %w", err)
83+
}
84+
85+
if analysis.GetId() == "" {
86+
return fmt.Errorf("build analysis requires a build ID")
87+
}
88+
89+
store, err := c.stores.For(storage.Config{QueueName: analysis.GetQueueName()})
90+
if err != nil {
91+
return fmt.Errorf("failed to resolve storage for queue %q: %w", analysis.GetQueueName(), err)
92+
}
93+
94+
build, err := loader.ByID(ctx, analysis.GetId(), store.GetBuildStore().Get, "build")
95+
if err != nil {
96+
return err
97+
}
98+
99+
request, err := loader.ByID(ctx, build.RequestID, store.GetRequestStore().Get, "request")
100+
if err != nil {
101+
return err
102+
}
103+
104+
if request.Queue != analysis.GetQueueName() {
105+
return fmt.Errorf("request queue %q does not match analysis queue %q", request.Queue, analysis.GetQueueName())
106+
}
107+
108+
// The Build may finish between the initial Build read and the Request read.
109+
// Recheck its status so stopping here does not skip final analysis.
110+
if !build.Status.IsTerminal() && request.State.IsTerminal() {
111+
build, err = loader.ByID(ctx, analysis.GetId(), store.GetBuildStore().Get, "build")
112+
if err != nil {
113+
return err
114+
}
115+
116+
if !build.Status.IsTerminal() {
117+
c.logger.Warnw("stopping analysis for a terminal request with nonterminal build",
118+
"queue", request.Queue, "build_id", build.ID, "request_id", request.ID, "request_state", string(request.State))
119+
return nil
120+
}
121+
}
122+
123+
detector, err := c.detectorFactory.For(buildissue.DetectorConfig{QueueName: analysis.GetQueueName()})
124+
if err != nil {
125+
return fmt.Errorf("failed to resolve issue detector for queue %q: %w", analysis.GetQueueName(), err)
126+
}
127+
128+
issues, err := detector.Detect(ctx, entity.BuildID{ID: build.ID})
129+
if err != nil {
130+
return fmt.Errorf("failed to detect issues for build %q: %w", build.ID, err)
131+
}
132+
133+
issueStore := store.GetBuildIssueStore()
134+
for _, issue := range issues {
135+
if err := issueStore.Create(ctx, build.ID, issue); err != nil && !errors.Is(err, storage.ErrAlreadyExists) {
136+
return fmt.Errorf("failed to persist build issue build=%q task=%q unit=%q: %w", build.ID, issue.TaskID, issue.UnitID, err)
137+
}
138+
}
139+
140+
// A nonterminal status read before detection requires another pass,
141+
// even if the build finishes while detection is running.
142+
if !build.Status.IsTerminal() {
143+
delivery.Hold(PollDelayMs)
144+
}
145+
146+
c.logger.Debugw("build analysis pass complete",
147+
"queue", analysis.GetQueueName(),
148+
"build_id", build.ID,
149+
"status", string(build.Status),
150+
"observed_issue_count", len(issues),
151+
)
152+
153+
return nil
154+
}
155+
156+
func (c *Controller) Name() string { return "buildanalysis" }
157+
158+
func (c *Controller) TopicKey() consumer.TopicKey { return c.topicKey }
159+
160+
func (c *Controller) ConsumerGroup() string { return c.consumerGroup }
161+
162+
var _ consumer.Controller = (*Controller)(nil)

0 commit comments

Comments
 (0)