diff --git a/stovepipe/controller/buildanalysis/BUILD.bazel b/stovepipe/controller/buildanalysis/BUILD.bazel new file mode 100644 index 000000000..d83fe9cc7 --- /dev/null +++ b/stovepipe/controller/buildanalysis/BUILD.bazel @@ -0,0 +1,43 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["buildanalysis.go"], + importpath = "github.com/uber/submitqueue/stovepipe/controller/buildanalysis", + visibility = ["//visibility:public"], + deps = [ + "//platform/base/messagequeue:go_default_library", + "//platform/consumer:go_default_library", + "//platform/metrics:go_default_library", + "//stovepipe/core/loader:go_default_library", + "//stovepipe/core/messagequeue:go_default_library", + "//stovepipe/entity:go_default_library", + "//stovepipe/extension/buildissue:go_default_library", + "//stovepipe/extension/storage:go_default_library", + "@com_github_uber_go_tally//:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["buildanalysis_test.go"], + embed = [":go_default_library"], + deps = [ + "//platform/base/messagequeue:go_default_library", + "//platform/consumer/mock:go_default_library", + "//platform/errs:go_default_library", + "//platform/metrics:go_default_library", + "//stovepipe/core/messagequeue:go_default_library", + "//stovepipe/entity:go_default_library", + "//stovepipe/extension/buildissue:go_default_library", + "//stovepipe/extension/buildissue/mock:go_default_library", + "//stovepipe/extension/storage:go_default_library", + "//stovepipe/extension/storage/mock:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + "@com_github_uber_go_tally//:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/stovepipe/controller/buildanalysis/README.md b/stovepipe/controller/buildanalysis/README.md new file mode 100644 index 000000000..aac7cde7b --- /dev/null +++ b/stovepipe/controller/buildanalysis/README.md @@ -0,0 +1,21 @@ +# Build analysis + +## Observations + +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. + +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. + +## Polling and completion + +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. + +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. + +Status polling and updates remain the responsibility of buildsignal. Analysis never changes Request state or releases build slots. + +## Errors + +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. + +The message and controller are defined without runtime producers or consumer registration. Host integration and detector implementations are separate work. diff --git a/stovepipe/controller/buildanalysis/buildanalysis.go b/stovepipe/controller/buildanalysis/buildanalysis.go new file mode 100644 index 000000000..4ebaff55e --- /dev/null +++ b/stovepipe/controller/buildanalysis/buildanalysis.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package buildanalysis accumulates issue observations during and after builds. +// Build status is read from storage; status polling remains separate. +package buildanalysis + +import ( + "context" + "errors" + "fmt" + + "github.com/uber-go/tally" + entityqueue "github.com/uber/submitqueue/platform/base/messagequeue" + "github.com/uber/submitqueue/platform/consumer" + "github.com/uber/submitqueue/platform/metrics" + "github.com/uber/submitqueue/stovepipe/core/loader" + stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue" + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/buildissue" + "github.com/uber/submitqueue/stovepipe/extension/storage" + "go.uber.org/zap" +) + +// PollDelayMs is the delay in milliseconds after a successful nonterminal pass. +// Two minutes limits provider traffic during long-running builds. +const PollDelayMs int64 = 120000 + +// Controller accumulates observations while builds run and after completion. +// Terminal analysis must finish persistence before its delivery completes. +type Controller struct { + logger *zap.SugaredLogger + metricsScope tally.Scope + stores storage.Factory + detectorFactory buildissue.DetectorFactory + topicKey consumer.TopicKey + consumerGroup string +} + +func NewController( + logger *zap.SugaredLogger, + scope tally.Scope, + stores storage.Factory, + detectorFactory buildissue.DetectorFactory, + topicKey consumer.TopicKey, + consumerGroup string, +) *Controller { + return &Controller{ + logger: logger.Named("buildanalysis_controller"), + metricsScope: scope.SubScope("buildanalysis_controller"), + stores: stores, + detectorFactory: detectorFactory, + topicKey: topicKey, + consumerGroup: consumerGroup, + } +} + +// Process holds after a successful nonterminal pass. Errors are returned for +// normal queue retry or DLQ handling; partial writes are safe to retry. +func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (retErr error) { + op := metrics.Begin(c.metricsScope, "process", metrics.LongLatencyBuckets, metrics.TagsFromContext(ctx)...) + defer func() { op.Complete(retErr) }() + + msg := delivery.Message() + analysis := &stovepipemq.BuildAnalysis{} + if err := stovepipemq.Unmarshal(msg.Payload, analysis); err != nil { + return fmt.Errorf("failed to deserialize build analysis: %w", err) + } + + if err := entityqueue.ValidatePayloadQueue(msg, analysis.GetQueueName()); err != nil { + return fmt.Errorf("invalid message identity: %w", err) + } + + if analysis.GetId() == "" { + return fmt.Errorf("build analysis requires a build ID") + } + + store, err := c.stores.For(storage.Config{QueueName: analysis.GetQueueName()}) + if err != nil { + return fmt.Errorf("failed to resolve storage for queue %q: %w", analysis.GetQueueName(), err) + } + + build, err := loader.ByID(ctx, analysis.GetId(), store.GetBuildStore().Get, "build") + if err != nil { + return err + } + + request, err := loader.ByID(ctx, build.RequestID, store.GetRequestStore().Get, "request") + if err != nil { + return err + } + + if request.Queue != analysis.GetQueueName() { + return fmt.Errorf("request queue %q does not match analysis queue %q", request.Queue, analysis.GetQueueName()) + } + + // The Build may finish between the initial Build read and the Request read. + // Recheck its status so stopping here does not skip final analysis. + if !build.Status.IsTerminal() && request.State.IsTerminal() { + build, err = loader.ByID(ctx, analysis.GetId(), store.GetBuildStore().Get, "build") + if err != nil { + return err + } + + if !build.Status.IsTerminal() { + c.logger.Warnw("stopping analysis for a terminal request with nonterminal build", + "queue", request.Queue, "build_id", build.ID, "request_id", request.ID, "request_state", string(request.State)) + return nil + } + } + + detector, err := c.detectorFactory.For(buildissue.DetectorConfig{QueueName: analysis.GetQueueName()}) + if err != nil { + return fmt.Errorf("failed to resolve issue detector for queue %q: %w", analysis.GetQueueName(), err) + } + + issues, err := detector.Detect(ctx, entity.BuildID{ID: build.ID}) + if err != nil { + return fmt.Errorf("failed to detect issues for build %q: %w", build.ID, err) + } + + issueStore := store.GetBuildIssueStore() + for _, issue := range issues { + if err := issueStore.Create(ctx, build.ID, issue); err != nil && !errors.Is(err, storage.ErrAlreadyExists) { + return fmt.Errorf("failed to persist build issue build=%q task=%q unit=%q: %w", build.ID, issue.TaskID, issue.UnitID, err) + } + } + + // A nonterminal status read before detection requires another pass, + // even if the build finishes while detection is running. + if !build.Status.IsTerminal() { + delivery.Hold(PollDelayMs) + } + + c.logger.Debugw("build analysis pass complete", + "queue", analysis.GetQueueName(), + "build_id", build.ID, + "status", string(build.Status), + "observed_issue_count", len(issues), + ) + + return nil +} + +func (c *Controller) Name() string { return "buildanalysis" } + +func (c *Controller) TopicKey() consumer.TopicKey { return c.topicKey } + +func (c *Controller) ConsumerGroup() string { return c.consumerGroup } + +var _ consumer.Controller = (*Controller)(nil) diff --git a/stovepipe/controller/buildanalysis/buildanalysis_test.go b/stovepipe/controller/buildanalysis/buildanalysis_test.go new file mode 100644 index 000000000..2cb3376e3 --- /dev/null +++ b/stovepipe/controller/buildanalysis/buildanalysis_test.go @@ -0,0 +1,466 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package buildanalysis + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber-go/tally" + entityqueue "github.com/uber/submitqueue/platform/base/messagequeue" + consumermock "github.com/uber/submitqueue/platform/consumer/mock" + "github.com/uber/submitqueue/platform/errs" + "github.com/uber/submitqueue/platform/metrics" + stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue" + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/buildissue" + buildissuemock "github.com/uber/submitqueue/stovepipe/extension/buildissue/mock" + "github.com/uber/submitqueue/stovepipe/extension/storage" + storagemock "github.com/uber/submitqueue/stovepipe/extension/storage/mock" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +const ( + testQueue = "validation/main" + testBuildID = "build/42" +) + +type analysisMocks struct { + request *entity.Request + ctrl *gomock.Controller + stores *storagemock.MockFactory + store *storagemock.MockStorage + builds *storagemock.MockBuildStore + requests *storagemock.MockRequestStore + issues *storagemock.MockBuildIssueStore + detectorFactory *buildissuemock.MockDetectorFactory + detector *buildissuemock.MockDetector + scope tally.TestScope +} + +func newTestController(t *testing.T) (*Controller, analysisMocks) { + t.Helper() + + ctrl := gomock.NewController(t) + m := analysisMocks{ + ctrl: ctrl, + requests: storagemock.NewMockRequestStore(ctrl), + request: &entity.Request{ID: "request/1", Queue: testQueue, State: entity.RequestStateProcessing}, + stores: storagemock.NewMockFactory(ctrl), + store: storagemock.NewMockStorage(ctrl), + builds: storagemock.NewMockBuildStore(ctrl), + issues: storagemock.NewMockBuildIssueStore(ctrl), + detectorFactory: buildissuemock.NewMockDetectorFactory(ctrl), + detector: buildissuemock.NewMockDetector(ctrl), + scope: tally.NewTestScope("test", nil), + } + + m.store.EXPECT().GetBuildStore().Return(m.builds).AnyTimes() + m.store.EXPECT().GetRequestStore().Return(m.requests).AnyTimes() + m.requests.EXPECT().Get(gomock.Any(), gomock.Any()).DoAndReturn(func(context.Context, string) (entity.Request, error) { return *m.request, nil }).AnyTimes() + m.store.EXPECT().GetBuildIssueStore().Return(m.issues).AnyTimes() + + c := NewController(zap.NewNop().Sugar(), m.scope, m.stores, m.detectorFactory, + stovepipemq.TopicKeyBuildAnalysis, "stovepipe-buildanalysis") + + return c, m +} + +func analysisMessage(t *testing.T) entityqueue.Message { + t.Helper() + payload, err := stovepipemq.Marshal(&stovepipemq.BuildAnalysis{Id: testBuildID, QueueName: testQueue}) + require.NoError(t, err) + + msg := entityqueue.NewMessage(testBuildID, payload, testBuildID, nil) + msg.Tenant = testQueue + + return msg +} + +func (m analysisMocks) delivery(t *testing.T) *consumermock.MockDelivery { + t.Helper() + d := consumermock.NewMockDelivery(m.ctrl) + d.EXPECT().Message().Return(analysisMessage(t)) + + return d +} + +func (m analysisMocks) expectPass(status entity.BuildStatus, issues []entity.BuildIssue, err error) *gomock.Call { + resolve := m.stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil) + load := m.builds.EXPECT().Get(gomock.Any(), testBuildID). + Return(entity.Build{ID: testBuildID, Status: status}, nil).After(resolve) + detector := m.detectorFactory.EXPECT().For(buildissue.DetectorConfig{QueueName: testQueue}).Return(m.detector, nil).After(load) + return m.detector.EXPECT().Detect(gomock.Any(), entity.BuildID{ID: testBuildID}).Return(issues, err).After(detector) +} + +func TestProcessPersistsBeforeHoldingOrCompleting(t *testing.T) { + issue := entity.BuildIssue{TaskID: "validation", UnitID: "unit-a"} + + for _, status := range []entity.BuildStatus{ + entity.BuildStatusAccepted, entity.BuildStatusRunning, entity.BuildStatusUnknown, + entity.BuildStatusSucceeded, entity.BuildStatusFailed, entity.BuildStatusCancelled, + } { + for _, empty := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/empty=%t", status, empty), func(t *testing.T) { + c, m := newTestController(t) + var issues []entity.BuildIssue + if !empty { + issues = []entity.BuildIssue{issue} + } + + last := m.expectPass(status, issues, nil) + if !empty { + last = m.issues.EXPECT().Create(gomock.Any(), testBuildID, issue).Return(nil).After(last) + } + + d := m.delivery(t) + if !status.IsTerminal() { + d.EXPECT().Hold(PollDelayMs).After(last) + } + + require.NoError(t, c.Process(context.Background(), d)) + }) + } + } +} + +func TestProcessAcceptsRepeatedAndTaskWideObservations(t *testing.T) { + c, m := newTestController(t) + issue := entity.BuildIssue{TaskID: "validation"} + + last := m.expectPass(entity.BuildStatusRunning, []entity.BuildIssue{issue, issue}, nil) + last = m.issues.EXPECT().Create(gomock.Any(), testBuildID, issue).Return(nil).After(last) + last = m.issues.EXPECT().Create(gomock.Any(), testBuildID, issue). + Return(fmt.Errorf("already recorded: %w", storage.ErrAlreadyExists)).After(last) + + d := m.delivery(t) + d.EXPECT().Hold(PollDelayMs).After(last) + + require.NoError(t, c.Process(context.Background(), d)) +} + +func TestProcessRetriesPartialPersistenceAndReloadsStatus(t *testing.T) { + c, m := newTestController(t) + a := entity.BuildIssue{TaskID: "validation", UnitID: "unit-a"} + b := entity.BuildIssue{TaskID: "validation", UnitID: "unit-b"} + writeErr := errs.NewRetryableError(assert.AnError) + + for _, pass := range []struct { + status entity.BuildStatus + firstErr error + secondErr error + }{ + {status: entity.BuildStatusRunning, secondErr: writeErr}, + {status: entity.BuildStatusRunning, firstErr: storage.ErrAlreadyExists}, + {status: entity.BuildStatusFailed, firstErr: storage.ErrAlreadyExists, secondErr: storage.ErrAlreadyExists}, + } { + + last := m.expectPass(pass.status, []entity.BuildIssue{a, b}, nil) + last = m.issues.EXPECT().Create(gomock.Any(), testBuildID, a).Return(pass.firstErr).After(last) + last = m.issues.EXPECT().Create(gomock.Any(), testBuildID, b).Return(pass.secondErr).After(last) + + d := m.delivery(t) + if pass.secondErr == writeErr { + err := c.Process(context.Background(), d) + require.ErrorIs(t, err, writeErr) + assert.True(t, errs.IsRetryable(err)) + } else { + if !pass.status.IsTerminal() { + d.EXPECT().Hold(PollDelayMs).After(last) + } + + require.NoError(t, c.Process(context.Background(), d)) + } + } +} + +func TestProcessRunsAgainWhenBuildCompletesDuringDetection(t *testing.T) { + c, m := newTestController(t) + status := entity.BuildStatusRunning + + m.stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil).Times(2) + m.builds.EXPECT().Get(gomock.Any(), testBuildID).DoAndReturn(func(context.Context, string) (entity.Build, error) { + return entity.Build{ID: testBuildID, Status: status}, nil + }).Times(2) + + m.detectorFactory.EXPECT().For(buildissue.DetectorConfig{QueueName: testQueue}).Return(m.detector, nil).Times(2) + issue := entity.BuildIssue{TaskID: "validation", UnitID: "final-unit"} + gomock.InOrder( + m.detector.EXPECT().Detect(gomock.Any(), entity.BuildID{ID: testBuildID}).DoAndReturn(func(context.Context, entity.BuildID) ([]entity.BuildIssue, error) { + status = entity.BuildStatusFailed + return nil, nil + }), + m.detector.EXPECT().Detect(gomock.Any(), entity.BuildID{ID: testBuildID}).Return([]entity.BuildIssue{issue}, nil), + ) + + first := m.delivery(t) + first.EXPECT().Hold(PollDelayMs) + + require.NoError(t, c.Process(context.Background(), first)) + + m.issues.EXPECT().Create(gomock.Any(), testBuildID, issue).Return(nil) + + require.NoError(t, c.Process(context.Background(), m.delivery(t))) +} + +func TestProcessDiscardsResultsOnDetectionError(t *testing.T) { + for _, status := range []entity.BuildStatus{ + entity.BuildStatusAccepted, entity.BuildStatusRunning, entity.BuildStatusUnknown, + entity.BuildStatusSucceeded, entity.BuildStatusFailed, entity.BuildStatusCancelled, + } { + for _, retryable := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/retryable=%t", status, retryable), func(t *testing.T) { + c, m := newTestController(t) + detectErr := error(assert.AnError) + if retryable { + detectErr = errs.NewRetryableError(detectErr) + } + + m.expectPass(status, []entity.BuildIssue{{TaskID: "validation", UnitID: "unit"}}, detectErr) + + err := c.Process(context.Background(), m.delivery(t)) + require.ErrorIs(t, err, detectErr) + assert.Equal(t, retryable, errs.IsRetryable(err)) + }) + } + } +} + +func TestProcessPropagatesDependencyErrors(t *testing.T) { + for _, step := range []string{"resolve storage", "load build", "resolve detector"} { + t.Run(step, func(t *testing.T) { + c, m := newTestController(t) + if step == "resolve storage" { + + m.stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(nil, assert.AnError) + } else { + + m.stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil) + if step == "load build" { + m.builds.EXPECT().Get(gomock.Any(), testBuildID).Return(entity.Build{}, assert.AnError) + } else { + m.builds.EXPECT().Get(gomock.Any(), testBuildID).Return(entity.Build{ID: testBuildID}, nil) + + m.detectorFactory.EXPECT().For(buildissue.DetectorConfig{QueueName: testQueue}).Return(nil, assert.AnError) + } + } + + require.ErrorIs(t, c.Process(context.Background(), m.delivery(t)), assert.AnError) + }) + } +} + +func TestProcessRejectsInvalidMessagesBeforeResolvingDependencies(t *testing.T) { + for _, kind := range []string{"malformed payload", "empty queue", "empty build ID", "empty tenant", "tenant mismatch", "metadata mismatch"} { + t.Run(kind, func(t *testing.T) { + c, m := newTestController(t) + msg := analysisMessage(t) + switch kind { + case "malformed payload": + msg.Payload = []byte("{") + case "empty queue": + msg.Payload = []byte(`{"id":"build/42"}`) + case "empty build ID": + msg.Payload = []byte(`{"queue_name":"validation/main"}`) + case "empty tenant": + msg.Tenant = "" + case "tenant mismatch": + msg.Tenant = "other/main" + case "metadata mismatch": + msg.Metadata[entityqueue.MetadataKeyQueueName] = "other/main" + } + + d := consumermock.NewMockDelivery(m.ctrl) + d.EXPECT().Message().Return(msg) + + err := c.Process(context.Background(), d) + require.Error(t, err) + assert.False(t, errs.IsRetryable(err)) + }) + } +} + +func TestProcessDoesNotTreatMissingBuildAsStorageLag(t *testing.T) { + c, m := newTestController(t) + + m.stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil) + m.builds.EXPECT().Get(gomock.Any(), testBuildID).Return(entity.Build{}, storage.ErrNotFound) + + err := c.Process(context.Background(), m.delivery(t)) + require.ErrorIs(t, err, storage.ErrNotFound) + assert.False(t, errs.IsRetryable(err)) +} + +func TestProcessTagsMetricsWithQueue(t *testing.T) { + c, m := newTestController(t) + + m.stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(nil, assert.AnError) + ctx := metrics.WithContextTags(context.Background(), metrics.NewTag("queue", testQueue)) + + require.Error(t, c.Process(ctx, m.delivery(t))) + + counter, ok := m.scope.Snapshot().Counters()["test.buildanalysis_controller.process.start+queue="+testQueue] + require.True(t, ok) + assert.EqualValues(t, 1, counter.Value()) +} + +func TestProcessReturnsPersistenceErrorsWithoutHoldingOrContinuing(t *testing.T) { + for _, status := range []entity.BuildStatus{entity.BuildStatusRunning, entity.BuildStatusFailed} { + t.Run(string(status), func(t *testing.T) { + c, m := newTestController(t) + first := entity.BuildIssue{TaskID: "validation", UnitID: "first"} + later := entity.BuildIssue{TaskID: "validation", UnitID: "later"} + writeErr := errs.NewRetryableError(assert.AnError) + + detected := m.expectPass(status, []entity.BuildIssue{first, later}, nil) + m.issues.EXPECT().Create(gomock.Any(), testBuildID, first).Return(writeErr).After(detected) + + require.ErrorIs(t, c.Process(context.Background(), m.delivery(t)), writeErr) + }) + } +} + +func TestProcessStopsWhenRequestEndsWithoutTerminalBuildStatus(t *testing.T) { + for _, state := range []entity.RequestState{entity.RequestStateFailed, entity.RequestStateCancelled, entity.RequestStateSuperseded, entity.RequestStateSucceeded} { + t.Run(string(state), func(t *testing.T) { + c, m := newTestController(t) + m.request.State = state + + m.stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil) + m.builds.EXPECT().Get(gomock.Any(), testBuildID).Return(entity.Build{ID: testBuildID, RequestID: m.request.ID, Status: entity.BuildStatusRunning}, nil).Times(2) + + require.NoError(t, c.Process(context.Background(), m.delivery(t))) + }) + } +} + +func TestProcessStillAnalyzesTerminalBuildOfTerminalRequest(t *testing.T) { + c, m := newTestController(t) + m.request.State = entity.RequestStateFailed + issue := entity.BuildIssue{TaskID: "validation", UnitID: "unit"} + + detected := m.expectPass(entity.BuildStatusFailed, []entity.BuildIssue{issue}, nil) + m.issues.EXPECT().Create(gomock.Any(), testBuildID, issue).Return(nil).After(detected) + + require.NoError(t, c.Process(context.Background(), m.delivery(t))) +} + +func TestProcessRejectsRequestFromDifferentQueue(t *testing.T) { + c, m := newTestController(t) + m.request.Queue = "other/main" + + m.stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil) + m.builds.EXPECT().Get(gomock.Any(), testBuildID).Return(entity.Build{ID: testBuildID, RequestID: m.request.ID}, nil) + + require.Error(t, c.Process(context.Background(), m.delivery(t))) +} + +func TestCompletionBetweenBuildAndRequestReadStillAnalyzes(t *testing.T) { + c, m := newTestController(t) + m.request.State = entity.RequestStateFailed + + m.stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil) + gomock.InOrder( + m.builds.EXPECT().Get(gomock.Any(), testBuildID).Return(entity.Build{ID: testBuildID, RequestID: m.request.ID, Status: entity.BuildStatusRunning}, nil), + m.builds.EXPECT().Get(gomock.Any(), testBuildID).Return(entity.Build{ID: testBuildID, RequestID: m.request.ID, Status: entity.BuildStatusFailed}, nil), + ) + + m.detectorFactory.EXPECT().For(buildissue.DetectorConfig{QueueName: testQueue}).Return(m.detector, nil) + issue := entity.BuildIssue{TaskID: "validation", UnitID: "unit"} + detected := m.detector.EXPECT().Detect(gomock.Any(), entity.BuildID{ID: testBuildID}).Return([]entity.BuildIssue{issue}, nil) + m.issues.EXPECT().Create(gomock.Any(), testBuildID, issue).Return(nil).After(detected) + + require.NoError(t, c.Process(context.Background(), m.delivery(t))) +} + +func TestProcessRetriesErrorsAndReloadsStatus(t *testing.T) { + c, m := newTestController(t) + status := entity.BuildStatusRunning + + m.stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil).Times(3) + m.builds.EXPECT().Get(gomock.Any(), testBuildID).DoAndReturn(func(context.Context, string) (entity.Build, error) { + return entity.Build{ID: testBuildID, Status: status}, nil + }).Times(3) + + m.detectorFactory.EXPECT().For(buildissue.DetectorConfig{QueueName: testQueue}).Return(m.detector, nil).Times(3) + issue := entity.BuildIssue{TaskID: "validation", UnitID: "final-unit"} + terminalErr := errs.NewRetryableError(assert.AnError) + gomock.InOrder( + m.detector.EXPECT().Detect(gomock.Any(), entity.BuildID{ID: testBuildID}).DoAndReturn(func(context.Context, entity.BuildID) ([]entity.BuildIssue, error) { + status = entity.BuildStatusFailed + return nil, terminalErr + }), + m.detector.EXPECT().Detect(gomock.Any(), entity.BuildID{ID: testBuildID}).Return(nil, terminalErr), + m.detector.EXPECT().Detect(gomock.Any(), entity.BuildID{ID: testBuildID}).Return([]entity.BuildIssue{issue}, nil), + ) + + first := m.delivery(t) + + require.ErrorIs(t, c.Process(context.Background(), first), terminalErr) + + err := c.Process(context.Background(), m.delivery(t)) + require.ErrorIs(t, err, terminalErr) + assert.True(t, errs.IsRetryable(err)) + + m.issues.EXPECT().Create(gomock.Any(), testBuildID, issue).Return(nil) + + require.NoError(t, c.Process(context.Background(), m.delivery(t))) +} + +func TestAnalysisFailureRemainsVisibleInMetrics(t *testing.T) { + c, m := newTestController(t) + m.expectPass(entity.BuildStatusRunning, nil, assert.AnError) + ctx := metrics.WithContextTags(context.Background(), metrics.NewTag("queue", testQueue)) + + require.ErrorIs(t, c.Process(ctx, m.delivery(t)), assert.AnError) + + _, ok := m.scope.Snapshot().Histograms()["test.buildanalysis_controller.process.finish+queue="+testQueue+",result=error"] + assert.True(t, ok) +} + +func TestCancelledAnalysisDoesNotHold(t *testing.T) { + for _, operation := range []string{"detect", "persist"} { + t.Run(operation, func(t *testing.T) { + c, m := newTestController(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if operation == "detect" { + + m.stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(m.store, nil) + m.builds.EXPECT().Get(gomock.Any(), testBuildID).Return(entity.Build{ID: testBuildID, Status: entity.BuildStatusRunning}, nil) + + m.detectorFactory.EXPECT().For(buildissue.DetectorConfig{QueueName: testQueue}).Return(m.detector, nil) + m.detector.EXPECT().Detect(gomock.Any(), entity.BuildID{ID: testBuildID}).DoAndReturn(func(context.Context, entity.BuildID) ([]entity.BuildIssue, error) { + cancel() + return nil, context.Canceled + }) + } else { + issue := entity.BuildIssue{TaskID: "validation", UnitID: "unit"} + + detected := m.expectPass(entity.BuildStatusRunning, []entity.BuildIssue{issue}, nil) + m.issues.EXPECT().Create(gomock.Any(), testBuildID, issue).DoAndReturn(func(context.Context, string, entity.BuildIssue) error { + cancel() + return context.Canceled + }).After(detected) + } + + require.ErrorIs(t, c.Process(ctx, m.delivery(t)), context.Canceled) + }) + } +} diff --git a/stovepipe/core/messagequeue/README.md b/stovepipe/core/messagequeue/README.md index e0c437c7a..225a8e238 100644 --- a/stovepipe/core/messagequeue/README.md +++ b/stovepipe/core/messagequeue/README.md @@ -9,4 +9,5 @@ Payloads are defined in proto3 (`proto/`, generated into `protopb/`) and seriali - **process** (`TopicKeyProcess`, `ProcessRequest`) — ingest publishes the minted request id here once it accepts a new head; the process controller reloads the `Request` from storage and decides the build strategy. Only the id travels: producer and consumer share the store, so messages stay small and redelivery is idempotent. - **build** (`TopicKeyBuild`, `BuildRequest`) — process publishes the request id here once its build scope (`BuildStrategy`/`BaseURI`) is decided; the build controller reloads the `Request`, triggers the build, and persists the resulting `Build`. Partitioned by request id. - **buildsignal** (`TopicKeyBuildSignal`, `BuildSignal`) — build publishes the build id here after triggering; buildsignal holds the delivery between polls until the build reaches a terminal status. Partitioned by build id, so each build's poll loop is an independent partition. +- **buildanalysis** (`TopicKeyBuildAnalysis`, `BuildAnalysis`) — identifies a persisted build for periodic issue detection. The controller holds between nonterminal passes and performs a final pass after observing terminal status. Partitioned by build id. This contract is not yet activated by runtime producers or consumers. - **record** (`TopicKeyRecord`, `Record`) — buildsignal publishes the request id after persisting the terminal outcome; record reloads the request and records its validation fact. Partitioned by request id. diff --git a/stovepipe/core/messagequeue/messagequeue.go b/stovepipe/core/messagequeue/messagequeue.go index 867eb3aaa..e9fb2c944 100644 --- a/stovepipe/core/messagequeue/messagequeue.go +++ b/stovepipe/core/messagequeue/messagequeue.go @@ -50,6 +50,9 @@ type ( // buildsignal re-publishes to itself while polling: the build id to poll. BuildSignal = protopb.BuildSignal + // BuildAnalysis identifies the build whose issue observations are polled. + BuildAnalysis = protopb.BuildAnalysis + // Record is the payload buildsignal publishes to the record stage once a // build reaches a terminal status: the build id to record. Record = protopb.Record diff --git a/stovepipe/core/messagequeue/messagequeue_test.go b/stovepipe/core/messagequeue/messagequeue_test.go index e0d0aa88d..cbcc1c884 100644 --- a/stovepipe/core/messagequeue/messagequeue_test.go +++ b/stovepipe/core/messagequeue/messagequeue_test.go @@ -80,7 +80,7 @@ func TestWireFormat(t *testing.T) { // no topic_keys option names an unknown key. func TestTopicKeysBindEveryTopicKey(t *testing.T) { bound := map[string]int{} - for _, m := range []proto.Message{&ProcessRequest{}, &BuildRequest{}, &BuildSignal{}, &Record{}} { + for _, m := range []proto.Message{&ProcessRequest{}, &BuildRequest{}, &BuildSignal{}, &BuildAnalysis{}, &Record{}} { keys := TopicKeys(m) require.NotEmpty(t, keys, "message must declare a non-empty topic_keys option") for _, key := range keys { @@ -92,6 +92,7 @@ func TestTopicKeysBindEveryTopicKey(t *testing.T) { TopicKeyProcess, TopicKeyBuild, TopicKeyBuildSignal, + TopicKeyBuildAnalysis, TopicKeyRecord, } @@ -104,3 +105,15 @@ func TestTopicKeysBindEveryTopicKey(t *testing.T) { assert.Truef(t, valid[key], "topic_keys option names unknown key %q", key) } } + +func TestBuildAnalysisRoundTrip(t *testing.T) { + analysis := &BuildAnalysis{Id: "build/42", QueueName: "validation/main"} + data, err := Marshal(analysis) + require.NoError(t, err) + + got := &BuildAnalysis{} + require.NoError(t, Unmarshal(data, got)) + + assert.True(t, proto.Equal(analysis, got)) + assert.JSONEq(t, `{"id":"build/42","queue_name":"validation/main"}`, string(data)) +} diff --git a/stovepipe/core/messagequeue/proto/BUILD.bazel b/stovepipe/core/messagequeue/proto/BUILD.bazel index 5f2390a8a..382e14f5b 100644 --- a/stovepipe/core/messagequeue/proto/BUILD.bazel +++ b/stovepipe/core/messagequeue/proto/BUILD.bazel @@ -1,6 +1,7 @@ exports_files( [ "build.proto", + "buildanalysis.proto", "buildsignal.proto", "process.proto", "record.proto", diff --git a/stovepipe/core/messagequeue/proto/buildanalysis.proto b/stovepipe/core/messagequeue/proto/buildanalysis.proto new file mode 100644 index 000000000..86ec72dd9 --- /dev/null +++ b/stovepipe/core/messagequeue/proto/buildanalysis.proto @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package uber.stovepipe.messagequeue; + +import "api/base/messagequeue/proto/messagequeue.proto"; + +option go_package = "github.com/uber/submitqueue/stovepipe/core/messagequeue/protopb"; +option java_multiple_files = true; +option java_outer_classname = "BuildAnalysisProto"; +option java_package = "com.uber.submitqueue.stovepipe.messagequeue"; + +// BuildAnalysis identifies a persisted build whose issues should be observed. +// The controller reloads build status on each delivery and holds between polls. +message BuildAnalysis { + option (uber.base.messagequeue.topic_keys) = "buildanalysis"; + + // id is the opaque runner-assigned build identifier. + string id = 1; + // queue_name is the required queue namespace of the build. + string queue_name = 2; +} diff --git a/stovepipe/core/messagequeue/protopb/BUILD.bazel b/stovepipe/core/messagequeue/protopb/BUILD.bazel index 7f61fb89a..f1d2656fe 100644 --- a/stovepipe/core/messagequeue/protopb/BUILD.bazel +++ b/stovepipe/core/messagequeue/protopb/BUILD.bazel @@ -4,6 +4,7 @@ go_library( name = "go_default_library", srcs = [ "build.pb.go", + "buildanalysis.pb.go", "buildsignal.pb.go", "process.pb.go", "record.pb.go", diff --git a/stovepipe/core/messagequeue/protopb/buildanalysis.pb.go b/stovepipe/core/messagequeue/protopb/buildanalysis.pb.go new file mode 100644 index 000000000..2c344967c --- /dev/null +++ b/stovepipe/core/messagequeue/protopb/buildanalysis.pb.go @@ -0,0 +1,153 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v5.29.3 +// source: buildanalysis.proto + +package protopb + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + _ "github.com/uber/submitqueue/api/base/messagequeue/protopb" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// BuildAnalysis identifies a persisted build whose issues should be observed. +// The controller reloads build status on each delivery and holds between polls. +type BuildAnalysis struct { + state protoimpl.MessageState `protogen:"open.v1"` + // id is the opaque runner-assigned build identifier. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // queue_name is the required queue namespace of the build. + QueueName string `protobuf:"bytes,2,opt,name=queue_name,json=queueName,proto3" json:"queue_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BuildAnalysis) Reset() { + *x = BuildAnalysis{} + mi := &file_buildanalysis_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BuildAnalysis) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BuildAnalysis) ProtoMessage() {} + +func (x *BuildAnalysis) ProtoReflect() protoreflect.Message { + mi := &file_buildanalysis_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BuildAnalysis.ProtoReflect.Descriptor instead. +func (*BuildAnalysis) Descriptor() ([]byte, []int) { + return file_buildanalysis_proto_rawDescGZIP(), []int{0} +} + +func (x *BuildAnalysis) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *BuildAnalysis) GetQueueName() string { + if x != nil { + return x.QueueName + } + return "" +} + +var File_buildanalysis_proto protoreflect.FileDescriptor + +const file_buildanalysis_proto_rawDesc = "" + + "\n" + + "\x13buildanalysis.proto\x12\x1buber.stovepipe.messagequeue\x1a.api/base/messagequeue/proto/messagequeue.proto\"Q\n" + + "\rBuildAnalysis\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1d\n" + + "\n" + + "queue_name\x18\x02 \x01(\tR\tqueueName:\x11\x8a\xb5\x18\rbuildanalysisB\x84\x01\n" + + "+com.uber.submitqueue.stovepipe.messagequeueB\x12BuildAnalysisProtoP\x01Z?github.com/uber/submitqueue/stovepipe/core/messagequeue/protopbb\x06proto3" + +var ( + file_buildanalysis_proto_rawDescOnce sync.Once + file_buildanalysis_proto_rawDescData []byte +) + +func file_buildanalysis_proto_rawDescGZIP() []byte { + file_buildanalysis_proto_rawDescOnce.Do(func() { + file_buildanalysis_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_buildanalysis_proto_rawDesc), len(file_buildanalysis_proto_rawDesc))) + }) + return file_buildanalysis_proto_rawDescData +} + +var file_buildanalysis_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_buildanalysis_proto_goTypes = []any{ + (*BuildAnalysis)(nil), // 0: uber.stovepipe.messagequeue.BuildAnalysis +} +var file_buildanalysis_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_buildanalysis_proto_init() } +func file_buildanalysis_proto_init() { + if File_buildanalysis_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_buildanalysis_proto_rawDesc), len(file_buildanalysis_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_buildanalysis_proto_goTypes, + DependencyIndexes: file_buildanalysis_proto_depIdxs, + MessageInfos: file_buildanalysis_proto_msgTypes, + }.Build() + File_buildanalysis_proto = out.File + file_buildanalysis_proto_goTypes = nil + file_buildanalysis_proto_depIdxs = nil +} diff --git a/stovepipe/core/messagequeue/topics.go b/stovepipe/core/messagequeue/topics.go index bac3f71d9..09da535a6 100644 --- a/stovepipe/core/messagequeue/topics.go +++ b/stovepipe/core/messagequeue/topics.go @@ -42,6 +42,9 @@ const ( // partition. TopicKeyBuildSignal TopicKey = "buildsignal" + // TopicKeyBuildAnalysis carries build IDs for issue detection, partitioned by build ID. + TopicKeyBuildAnalysis TopicKey = "buildanalysis" + // TopicKeyRecord carries a request whose build reached a terminal status from // buildsignal to the record stage. The buildsignal controller publishes a // Record (the request id) here once, and only once, a build reaches a diff --git a/stovepipe/extension/buildissue/README.md b/stovepipe/extension/buildissue/README.md index ac96ce3bc..691014210 100644 --- a/stovepipe/extension/buildissue/README.md +++ b/stovepipe/extension/buildissue/README.md @@ -10,4 +10,4 @@ Each detection returns issues discoverable at that time and may repeat earlier o Detectors must support concurrent calls. Dependency failures return plain errors; issue results returned with an error must not be used. -`Factory` resolves the detector for a validation queue. +`DetectorFactory` resolves the detector for a validation queue. diff --git a/stovepipe/extension/buildissue/buildissue.go b/stovepipe/extension/buildissue/buildissue.go index c2d64d8cc..c5fdcd0b9 100644 --- a/stovepipe/extension/buildissue/buildissue.go +++ b/stovepipe/extension/buildissue/buildissue.go @@ -33,13 +33,13 @@ type Detector interface { Detect(ctx context.Context, buildID entity.BuildID) ([]entity.BuildIssue, error) } -// Config identifies the queue whose issue detector is requested. -type Config struct { +// DetectorConfig identifies the queue whose issue detector is requested. +type DetectorConfig struct { // QueueName identifies the queue. QueueName string } -// Factory resolves a queue's detector; implementations and routing belong to host wiring. -type Factory interface { - For(cfg Config) (Detector, error) +// DetectorFactory resolves a detector for a validation queue. +type DetectorFactory interface { + For(cfg DetectorConfig) (Detector, error) } diff --git a/stovepipe/extension/buildissue/mock/buildissue_mock.go b/stovepipe/extension/buildissue/mock/buildissue_mock.go index 3cca63ce7..d14ee2262 100644 --- a/stovepipe/extension/buildissue/mock/buildissue_mock.go +++ b/stovepipe/extension/buildissue/mock/buildissue_mock.go @@ -57,32 +57,32 @@ func (mr *MockDetectorMockRecorder) Detect(ctx, buildID any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Detect", reflect.TypeOf((*MockDetector)(nil).Detect), ctx, buildID) } -// MockFactory is a mock of Factory interface. -type MockFactory struct { +// MockDetectorFactory is a mock of DetectorFactory interface. +type MockDetectorFactory struct { ctrl *gomock.Controller - recorder *MockFactoryMockRecorder + recorder *MockDetectorFactoryMockRecorder isgomock struct{} } -// MockFactoryMockRecorder is the mock recorder for MockFactory. -type MockFactoryMockRecorder struct { - mock *MockFactory +// MockDetectorFactoryMockRecorder is the mock recorder for MockDetectorFactory. +type MockDetectorFactoryMockRecorder struct { + mock *MockDetectorFactory } -// NewMockFactory creates a new mock instance. -func NewMockFactory(ctrl *gomock.Controller) *MockFactory { - mock := &MockFactory{ctrl: ctrl} - mock.recorder = &MockFactoryMockRecorder{mock} +// NewMockDetectorFactory creates a new mock instance. +func NewMockDetectorFactory(ctrl *gomock.Controller) *MockDetectorFactory { + mock := &MockDetectorFactory{ctrl: ctrl} + mock.recorder = &MockDetectorFactoryMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { +func (m *MockDetectorFactory) EXPECT() *MockDetectorFactoryMockRecorder { return m.recorder } // For mocks base method. -func (m *MockFactory) For(cfg buildissue.Config) (buildissue.Detector, error) { +func (m *MockDetectorFactory) For(cfg buildissue.DetectorConfig) (buildissue.Detector, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "For", cfg) ret0, _ := ret[0].(buildissue.Detector) @@ -91,7 +91,7 @@ func (m *MockFactory) For(cfg buildissue.Config) (buildissue.Detector, error) { } // For indicates an expected call of For. -func (mr *MockFactoryMockRecorder) For(cfg any) *gomock.Call { +func (mr *MockDetectorFactoryMockRecorder) For(cfg any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockFactory)(nil).For), cfg) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockDetectorFactory)(nil).For), cfg) } diff --git a/tool/proto/BUILD.bazel b/tool/proto/BUILD.bazel index 8c6037e11..62be9e389 100644 --- a/tool/proto/BUILD.bazel +++ b/tool/proto/BUILD.bazel @@ -103,6 +103,7 @@ go_proto_generated_files( name = "stovepipe_core_messagequeue", srcs = [ "//stovepipe/core/messagequeue/proto:build.proto", + "//stovepipe/core/messagequeue/proto:buildanalysis.proto", "//stovepipe/core/messagequeue/proto:buildsignal.proto", "//stovepipe/core/messagequeue/proto:process.proto", "//stovepipe/core/messagequeue/proto:record.proto",