Skip to content

Commit 47b4e9f

Browse files
prathshenoymnoah1
authored andcommitted
feat(stovepipe): Record project validation facts
**What**: - Record named project outcomes alongside final repository validation results. - Let deployments without project attribution continue recording only repository results. **Why**: - Enable consumers to query durable project failures when an integration supplies attribution.
1 parent 0ffeb77 commit 47b4e9f

7 files changed

Lines changed: 189 additions & 16 deletions

File tree

‎service/stovepipe/server/BUILD.bazel‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ go_library(
3333
"//stovepipe/core/requestlog:go_default_library",
3434
"//stovepipe/extension/buildrunner:go_default_library",
3535
"//stovepipe/extension/buildrunner/fake:go_default_library",
36+
"//stovepipe/extension/projectresult:go_default_library",
3637
"//stovepipe/extension/queueconfig/default:go_default_library",
3738
"//stovepipe/extension/sourcecontrol:go_default_library",
3839
"//stovepipe/extension/sourcecontrol/fake:go_default_library",

‎service/stovepipe/server/main.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import (
5555
"github.com/uber/submitqueue/stovepipe/core/requestlog"
5656
"github.com/uber/submitqueue/stovepipe/extension/buildrunner"
5757
buildrunnerfake "github.com/uber/submitqueue/stovepipe/extension/buildrunner/fake"
58+
"github.com/uber/submitqueue/stovepipe/extension/projectresult"
5859
queueconfigdefault "github.com/uber/submitqueue/stovepipe/extension/queueconfig/default"
5960
"github.com/uber/submitqueue/stovepipe/extension/sourcecontrol"
6061
sourcecontrolfake "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol/fake"
@@ -440,7 +441,7 @@ func registerPrimaryControllers(
440441
}
441442
count++
442443

443-
recordController := record.NewController(logger, scope, store, materializer, sourceControl, registry, stovepipemq.TopicKeyRecord, "stovepipe-record")
444+
recordController := record.NewController(logger, scope, store, materializer, projectresult.NoopFactory{}, sourceControl, registry, stovepipemq.TopicKeyRecord, "stovepipe-record")
444445
if err := c.Register(recordController); err != nil {
445446
return count, fmt.Errorf("failed to register record controller: %w", err)
446447
}
@@ -486,7 +487,7 @@ func registerDLQControllers(
486487
}
487488
count++
488489

489-
recordDLQController := record.NewController(logger, scope, store, materializer, sourceControl, registry, dlq.TopicKey(stovepipemq.TopicKeyRecord), "stovepipe-record-dlq")
490+
recordDLQController := record.NewController(logger, scope, store, materializer, projectresult.NoopFactory{}, sourceControl, registry, dlq.TopicKey(stovepipemq.TopicKeyRecord), "stovepipe-record-dlq")
490491
if err := c.Register(recordDLQController); err != nil {
491492
return count, fmt.Errorf("failed to register record dlq controller: %w", err)
492493
}

‎stovepipe/controller/record/BUILD.bazel‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ go_library(
1616
"//stovepipe/core/messagequeue:go_default_library",
1717
"//stovepipe/core/requestlog:go_default_library",
1818
"//stovepipe/entity:go_default_library",
19+
"//stovepipe/extension/projectresult:go_default_library",
1920
"//stovepipe/extension/sourcecontrol:go_default_library",
2021
"//stovepipe/extension/storage:go_default_library",
2122
"@com_github_uber_go_tally//:go_default_library",
@@ -39,6 +40,7 @@ go_test(
3940
"//stovepipe/core/requestlog:go_default_library",
4041
"//stovepipe/core/requestlog/mock:go_default_library",
4142
"//stovepipe/entity:go_default_library",
43+
"//stovepipe/extension/projectresult:go_default_library",
4244
"//stovepipe/extension/sourcecontrol:go_default_library",
4345
"//stovepipe/extension/sourcecontrol/mock:go_default_library",
4446
"//stovepipe/extension/storage:go_default_library",

‎stovepipe/controller/record/record.go‎

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"encoding/hex"
3434
"errors"
3535
"fmt"
36+
"math"
3637
"strconv"
3738
"time"
3839

@@ -47,6 +48,7 @@ import (
4748
stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue"
4849
"github.com/uber/submitqueue/stovepipe/core/requestlog"
4950
"github.com/uber/submitqueue/stovepipe/entity"
51+
"github.com/uber/submitqueue/stovepipe/extension/projectresult"
5052
"github.com/uber/submitqueue/stovepipe/extension/sourcecontrol"
5153
"github.com/uber/submitqueue/stovepipe/extension/storage"
5254
"go.uber.org/zap"
@@ -60,6 +62,7 @@ type Controller struct {
6062
metricsScope tally.Scope
6163
stores storage.Factory
6264
materializer requestlog.Materializer
65+
projectResult projectresult.Factory
6366
sourceControl sourcecontrol.Factory
6467
registry consumer.TopicRegistry
6568
topicKey consumer.TopicKey
@@ -73,8 +76,7 @@ var _ consumer.Controller = (*Controller)(nil)
7376
const _opName = "record"
7477

7578
// wholeRepositoryProject is the project component of a fact covering the whole
76-
// repository rather than one project within it. Per-project facts need target-graph
77-
// attribution that this stage does not do, so every fact it writes is whole-repository.
79+
// repository rather than one project within it.
7880
const wholeRepositoryProject = ""
7981

8082
// NewController creates a new record controller.
@@ -83,6 +85,7 @@ func NewController(
8385
scope tally.Scope,
8486
stores storage.Factory,
8587
materializer requestlog.Materializer,
88+
projectResult projectresult.Factory,
8689
sourceControl sourcecontrol.Factory,
8790
registry consumer.TopicRegistry,
8891
topicKey consumer.TopicKey,
@@ -94,6 +97,7 @@ func NewController(
9497
metricsScope: scope.SubScope(name),
9598
stores: stores,
9699
materializer: materializer,
100+
projectResult: projectResult,
97101
sourceControl: sourceControl,
98102
registry: registry,
99103
topicKey: topicKey,
@@ -150,6 +154,9 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
150154
if err := c.persistValidationFactRecordedLog(ctx, store, request, fact); err != nil {
151155
return err
152156
}
157+
if err := c.recordProjectFacts(ctx, store, request); err != nil {
158+
return err
159+
}
153160
if err := c.applyFactToDerivedCaches(ctx, store, request, fact, created); err != nil {
154161
return err
155162
}
@@ -176,6 +183,46 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
176183
}
177184
}
178185

186+
func (c *Controller) recordProjectFacts(ctx context.Context, store storage.Storage, request entity.Request) error {
187+
resolver, err := c.projectResult.For(projectresult.Config{QueueName: request.Queue})
188+
if err != nil {
189+
return fmt.Errorf("failed to resolve project result resolver for queue %q: %w", request.Queue, err)
190+
}
191+
results, err := resolver.Resolve(ctx, request)
192+
if err != nil {
193+
return fmt.Errorf("failed to resolve project results for request %q: %w", request.ID, err)
194+
}
195+
196+
seen := make(map[string]struct{}, len(results))
197+
for _, result := range results {
198+
if result.Project == "" {
199+
return fmt.Errorf("project result for request %q has an empty project", request.ID)
200+
}
201+
if _, ok := seen[result.Project]; ok {
202+
return fmt.Errorf("project result for request %q contains duplicate project %q", request.ID, result.Project)
203+
}
204+
seen[result.Project] = struct{}{}
205+
if math.IsNaN(result.Degree) || result.Degree < entity.DegreeGreen || result.Degree > entity.DegreeBroken {
206+
return fmt.Errorf("project result for request %q and project %q has invalid degree %v", request.ID, result.Project, result.Degree)
207+
}
208+
209+
fact, _, err := c.recordValidationFact(ctx, store.GetValidationFactStore(), entity.ValidationFact{
210+
URI: request.URI,
211+
Project: result.Project,
212+
Degree: result.Degree,
213+
RequestID: request.ID,
214+
CreatedAt: time.Now().UnixMilli(),
215+
})
216+
if err != nil {
217+
return err
218+
}
219+
if err := c.persistValidationFactRecordedLog(ctx, store, request, fact); err != nil {
220+
return err
221+
}
222+
}
223+
return nil
224+
}
225+
179226
func (c *Controller) persistValidationFactRecordedLog(
180227
ctx context.Context,
181228
store storage.Storage,
@@ -241,48 +288,49 @@ func (c *Controller) applyFactToDerivedCaches(
241288
// second return reports whether this call is the one that wrote the fact, which is
242289
// how a caller tells the original delivery from a redelivery.
243290
func (c *Controller) recordFact(ctx context.Context, store storage.Storage, request entity.Request) (entity.ValidationFact, bool, error) {
244-
factStore := store.GetValidationFactStore()
245-
246-
fact := entity.ValidationFact{
291+
return c.recordValidationFact(ctx, store.GetValidationFactStore(), entity.ValidationFact{
247292
URI: request.URI,
248293
Project: wholeRepositoryProject,
249294
Degree: degreeFor(request.State),
250295
RequestID: request.ID,
251296
CreatedAt: time.Now().UnixMilli(),
252-
}
297+
})
298+
}
299+
300+
func (c *Controller) recordValidationFact(ctx context.Context, factStore storage.ValidationFactStore, fact entity.ValidationFact) (entity.ValidationFact, bool, error) {
253301

254302
err := factStore.Create(ctx, fact)
255303
switch {
256304
case err == nil:
257305
metrics.NamedCounter(c.metricsScope, _opName, "fact_created", 1, metrics.TagsFromContext(ctx)...)
258306
c.logger.Infow("recorded validation fact",
259-
"queue", request.Queue,
260-
"request_id", request.ID,
261-
"uri", request.URI,
307+
"request_id", fact.RequestID,
308+
"uri", fact.URI,
309+
"project", fact.Project,
262310
"degree", fact.Degree,
263311
)
264312
return fact, true, nil
265313

266314
case errors.Is(err, storage.ErrAlreadyExists):
267-
stored, getErr := factStore.Get(ctx, request.URI, wholeRepositoryProject)
315+
stored, getErr := factStore.Get(ctx, fact.URI, fact.Project)
268316
if getErr != nil {
269317
metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1, metrics.TagsFromContext(ctx)...)
270-
return entity.ValidationFact{}, false, fmt.Errorf("failed to load the existing fact for uri %s: %w", request.URI, getErr)
318+
return entity.ValidationFact{}, false, fmt.Errorf("failed to load the existing fact for uri %s and project %q: %w", fact.URI, fact.Project, getErr)
271319
}
272-
if stored.RequestID != request.ID {
320+
if stored.RequestID != fact.RequestID {
273321
// Two requests validating one URI would break the dedup ingest
274322
// enforces, so this is a broken invariant rather than a race to
275323
// resolve. Non-retryable: the stored fact is immutable.
276324
metrics.NamedCounter(c.metricsScope, _opName, "invariant_errors", 1, metrics.TagsFromContext(ctx)...)
277325
return entity.ValidationFact{}, false, fmt.Errorf(
278-
"fact for uri %s is owned by request %s, not %s", request.URI, stored.RequestID, request.ID)
326+
"fact for uri %s and project %q is owned by request %s, not %s", fact.URI, fact.Project, stored.RequestID, fact.RequestID)
279327
}
280328
metrics.NamedCounter(c.metricsScope, _opName, "fact_exists", 1, metrics.TagsFromContext(ctx)...)
281329
return stored, false, nil
282330

283331
default:
284332
metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1, metrics.TagsFromContext(ctx)...)
285-
return entity.ValidationFact{}, false, fmt.Errorf("failed to create the fact for uri %s: %w", request.URI, err)
333+
return entity.ValidationFact{}, false, fmt.Errorf("failed to create the fact for uri %s and project %q: %w", fact.URI, fact.Project, err)
286334
}
287335
}
288336

‎stovepipe/controller/record/record_test.go‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"github.com/uber/submitqueue/stovepipe/core/requestlog"
3535
requestlogmock "github.com/uber/submitqueue/stovepipe/core/requestlog/mock"
3636
"github.com/uber/submitqueue/stovepipe/entity"
37+
"github.com/uber/submitqueue/stovepipe/extension/projectresult"
3738
"github.com/uber/submitqueue/stovepipe/extension/sourcecontrol"
3839
sourcecontrolmock "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol/mock"
3940
"github.com/uber/submitqueue/stovepipe/extension/storage"
@@ -125,6 +126,23 @@ func (f staticSourceControlFactory) For(sourcecontrol.Config) (sourcecontrol.Sou
125126
return f.sourceControl, nil
126127
}
127128

129+
type staticProjectResultFactory struct {
130+
resolver projectresult.Resolver
131+
}
132+
133+
func (f staticProjectResultFactory) For(projectresult.Config) (projectresult.Resolver, error) {
134+
return f.resolver, nil
135+
}
136+
137+
type staticProjectResultResolver struct {
138+
results []projectresult.Result
139+
err error
140+
}
141+
142+
func (r staticProjectResultResolver) Resolve(context.Context, entity.Request) ([]projectresult.Result, error) {
143+
return r.results, r.err
144+
}
145+
128146
// failingSourceControlFactory resolves no queue.
129147
type failingSourceControlFactory struct{}
130148

@@ -194,6 +212,7 @@ func newControllerForTopic(t *testing.T, ctrl *gomock.Controller, topicKey consu
194212
scope,
195213
staticStorageFactory{store: m.store},
196214
m.materializer,
215+
projectresult.NoopFactory{},
197216
staticSourceControlFactory{sourceControl: m.sourceControl},
198217
registry,
199218
topicKey,
@@ -325,6 +344,34 @@ func TestProcess_AdvancesBookmarkOnSuccess(t *testing.T) {
325344
}
326345
}
327346

347+
func TestProcess_RecordsNamedProjectResults(t *testing.T) {
348+
ctrl := gomock.NewController(t)
349+
c, m := newController(t, ctrl)
350+
c.projectResult = staticProjectResultFactory{resolver: staticProjectResultResolver{results: []projectresult.Result{
351+
{Project: "project-a", Degree: entity.DegreeBroken},
352+
{Project: "project-b", Degree: entity.DegreeBroken},
353+
}}}
354+
m.reqStore.EXPECT().Get(gomock.Any(), testID).Return(requestWithState(entity.RequestStateFailed), nil)
355+
356+
var facts []entity.ValidationFact
357+
m.factStore.EXPECT().Create(gomock.Any(), gomock.Any()).
358+
DoAndReturn(func(_ context.Context, fact entity.ValidationFact) error {
359+
facts = append(facts, fact)
360+
return nil
361+
}).
362+
Times(3)
363+
364+
require.NoError(t, c.Process(queueContext(), delivery(t, ctrl, recordPayload(t, testID))))
365+
require.Len(t, facts, 3)
366+
assert.Equal(t, []string{"", "project-a", "project-b"}, []string{facts[0].Project, facts[1].Project, facts[2].Project})
367+
for _, fact := range facts {
368+
assert.Equal(t, testURI, fact.URI)
369+
assert.Equal(t, testID, fact.RequestID)
370+
assert.Equal(t, entity.DegreeBroken, fact.Degree)
371+
assert.Positive(t, fact.CreatedAt)
372+
}
373+
}
374+
328375
func TestProcess_TimestampReportingFailureDoesNotFailRecord(t *testing.T) {
329376
tests := []struct {
330377
name string
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
load("@rules_go//go:def.bzl", "go_library")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["projectresult.go"],
6+
importpath = "github.com/uber/submitqueue/stovepipe/extension/projectresult",
7+
visibility = ["//visibility:public"],
8+
deps = ["//stovepipe/entity:go_default_library"],
9+
)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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 projectresult defines the optional integration that attributes a
16+
// completed validation to named projects.
17+
package projectresult
18+
19+
import (
20+
"context"
21+
22+
"github.com/uber/submitqueue/stovepipe/entity"
23+
)
24+
25+
// Result is one project-scoped validation outcome. The record stage supplies
26+
// the request identity and recording timestamp when it persists this result.
27+
type Result struct {
28+
// Project identifies the project to which this result applies.
29+
Project string
30+
// Degree describes how broken the project is, on the closed interval [0, 1].
31+
Degree float64
32+
}
33+
34+
// Resolver attributes one terminal validation request to named project
35+
// outcomes. Implementations may use any repository-specific analysis they need
36+
// to obtain those outcomes. Returning no results is valid.
37+
type Resolver interface {
38+
Resolve(ctx context.Context, request entity.Request) ([]Result, error)
39+
}
40+
41+
// Config carries the queue identity handed to a Factory.
42+
type Config struct {
43+
// QueueName identifies the queue served by the resolver.
44+
QueueName string
45+
}
46+
47+
// Factory constructs a Resolver for one queue.
48+
type Factory interface {
49+
For(cfg Config) (Resolver, error)
50+
}
51+
52+
// NoopFactory returns a resolver that records no project outcomes. It is the
53+
// default for deployments that do not configure project attribution.
54+
type NoopFactory struct{}
55+
56+
// For returns the no-op resolver for a queue.
57+
func (NoopFactory) For(Config) (Resolver, error) {
58+
return noopResolver{}, nil
59+
}
60+
61+
type noopResolver struct{}
62+
63+
func (noopResolver) Resolve(context.Context, entity.Request) ([]Result, error) {
64+
return nil, nil
65+
}

0 commit comments

Comments
 (0)