Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/stovepipe/proto/stovepipe.proto
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ message ProjectValidation {
oneof result {
double breakage_degree = 2;
}
// Provider-defined project classification; empty means unknown.
string project_type = 3;
}

// GetProjectStatusByIDResponse contains the selected validation's current projection.
Expand Down
16 changes: 13 additions & 3 deletions api/stovepipe/protopb/stovepipe.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

185 changes: 93 additions & 92 deletions api/stovepipe/protopb/stovepipe.pb.yarpc.go

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion service/stovepipe/server/mapper/project_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ func validationFactsToProto(facts []entity.ValidationFact) []*pb.ProjectValidati
for _, fact := range facts {
degree := fact.Degree
projects = append(projects, &pb.ProjectValidation{
Project: fact.Project,
Project: fact.Project,
ProjectType: fact.ProjectType,
Result: &pb.ProjectValidation_BreakageDegree{
BreakageDegree: degree,
},
Expand Down
12 changes: 8 additions & 4 deletions service/stovepipe/server/mapper/project_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestProjectStatusRequestAndResponseMapping(t *testing.T) {
RequestSummary: entity.RequestSummary{RequestID: "1", Queue: "queue", URI: "uri", BaseURI: "base", State: entity.RequestStateSucceeded},
RepositoryValidationFact: entity.ValidationFact{Degree: degree},
HasRepositoryValidationFact: true,
ProjectValidationFacts: []entity.ValidationFact{{Project: "project-a", Degree: entity.DegreeBroken}},
ProjectValidationFacts: []entity.ValidationFact{{Project: "project-a", ProjectType: "service", Degree: entity.DegreeBroken}},
NextPageToken: "next-token",
})
assert.Equal(t, "1", response.GetRequestId())
Expand All @@ -47,6 +47,7 @@ func TestProjectStatusRequestAndResponseMapping(t *testing.T) {
return
}
assert.Equal(t, "project-a", response.GetProjects()[0].GetProject())
assert.Equal(t, "service", response.GetProjects()[0].GetProjectType())
assert.Equal(t, entity.DegreeBroken, response.GetProjects()[0].GetBreakageDegree())
assert.IsType(t, &pb.ProjectValidation_BreakageDegree{}, response.GetProjects()[0].GetResult())
assert.Equal(t, "next-token", response.GetNextPageToken())
Expand All @@ -59,8 +60,9 @@ func TestProjectStatusMappingPreservesExplicitGreenResults(t *testing.T) {
Degree: entity.DegreeGreen,
},
ProjectValidationFacts: []entity.ValidationFact{{
Project: "project-a",
Degree: entity.DegreeGreen,
Project: "project-a",
ProjectType: "service",
Degree: entity.DegreeGreen,
}},
})

Expand All @@ -83,7 +85,7 @@ func TestProjectStatusByIDRequestAndResponseMapping(t *testing.T) {
HasRepositoryValidationFact: hasFact,
RepositoryValidationFact: entity.ValidationFact{Degree: entity.DegreeGreen},
ProjectResultsComplete: true,
ProjectValidationFacts: []entity.ValidationFact{{Project: "project-a", Degree: entity.DegreeGreen}, {Project: "project-b", Degree: entity.DegreeBroken}},
ProjectValidationFacts: []entity.ValidationFact{{Project: "project-a", ProjectType: "service", Degree: entity.DegreeGreen}, {Project: "project-b", Degree: entity.DegreeBroken}},
NextPageToken: "next-token",
UpdatedAtMs: 123,
}
Expand All @@ -104,6 +106,8 @@ func TestProjectStatusByIDRequestAndResponseMapping(t *testing.T) {
}
if assert.Len(t, response.Projects, 2) {
assert.Equal(t, "project-a", response.Projects[0].GetProject())
assert.Equal(t, "service", response.Projects[0].GetProjectType())
assert.Empty(t, response.Projects[1].GetProjectType())
assert.IsType(t, &pb.ProjectValidation_BreakageDegree{}, response.Projects[0].GetResult())
assert.Equal(t, entity.DegreeGreen, response.Projects[0].GetBreakageDegree())
assert.Equal(t, "project-b", response.Projects[1].GetProject())
Expand Down
21 changes: 11 additions & 10 deletions stovepipe/controller/record/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,12 @@ func (c *Controller) persistProjectFacts(ctx context.Context, store storage.Stor
brokenCount := 0
for _, result := range results {
fact, _, err := recordValidationFact(ctx, c.logger, c.metricsScope, store.GetValidationFactStore(), entity.ValidationFact{
URI: request.URI,
Project: result.Project,
Degree: result.Degree,
RequestID: request.ID,
CreatedAt: time.Now().UnixMilli(),
URI: request.URI,
Project: result.ProjectInfo.Name,
ProjectType: result.ProjectInfo.ProjectType,
Degree: result.Degree,
RequestID: request.ID,
CreatedAt: time.Now().UnixMilli(),
})
if err != nil {
return err
Expand All @@ -240,15 +241,15 @@ func (c *Controller) persistProjectFacts(ctx context.Context, store storage.Stor
func validateProjectStatusResults(request entity.Request, results []projectstatus.Result) error {
seen := make(map[string]struct{}, len(results))
for _, result := range results {
if result.Project == "" {
if result.ProjectInfo.Name == "" {
return fmt.Errorf("project status result for request %q has an empty project", request.ID)
}
if _, ok := seen[result.Project]; ok {
return fmt.Errorf("project status result for request %q contains duplicate project %q", request.ID, result.Project)
if _, ok := seen[result.ProjectInfo.Name]; ok {
return fmt.Errorf("project status result for request %q contains duplicate project %q", request.ID, result.ProjectInfo.Name)
}
seen[result.Project] = struct{}{}
seen[result.ProjectInfo.Name] = struct{}{}
if math.IsNaN(result.Degree) || result.Degree < entity.DegreeGreen || result.Degree > entity.DegreeBroken {
return fmt.Errorf("project status result for request %q and project %q has invalid degree %v", request.ID, result.Project, result.Degree)
return fmt.Errorf("project status result for request %q and project %q has invalid degree %v", request.ID, result.ProjectInfo.Name, result.Degree)
}
}
return nil
Expand Down
39 changes: 20 additions & 19 deletions stovepipe/controller/record/record_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,8 +337,8 @@ func TestProcess_RecordsNamedProjectStatusResults(t *testing.T) {
projectStatusResolver.EXPECT().Resolve(
gomock.Any(), requestWithState(entity.RequestStateFailed), "bk-1",
).Return([]projectstatus.Result{
{Project: "project-a", Degree: entity.DegreeBroken},
{Project: "project-b", Degree: entity.DegreeGreen},
{ProjectInfo: projectstatus.ProjectInfo{Name: "project-a"}, Degree: entity.DegreeBroken},
{ProjectInfo: projectstatus.ProjectInfo{Name: "project-b"}, Degree: entity.DegreeGreen},
}, nil)
m.reqStore.EXPECT().Get(gomock.Any(), testID).Return(requestWithState(entity.RequestStateFailed), nil)

Expand Down Expand Up @@ -414,8 +414,8 @@ func TestRecordProjectFacts_PassesEmptyTerminalBuild(t *testing.T) {

func TestResolveProjectResultsMetrics(t *testing.T) {
validResults := []projectstatus.Result{
{Project: "project-a", Degree: entity.DegreeGreen},
{Project: "project-b", Degree: entity.DegreeBroken},
{ProjectInfo: projectstatus.ProjectInfo{Name: "project-a"}, Degree: entity.DegreeGreen},
{ProjectInfo: projectstatus.ProjectInfo{Name: "project-b"}, Degree: entity.DegreeBroken},
}
for _, tt := range []struct {
name string
Expand All @@ -434,7 +434,7 @@ func TestResolveProjectResultsMetrics(t *testing.T) {
{name: "resolver cancellation", resolveErr: context.Canceled, wantResult: "cancel"},
{name: "resolver deadline", resolveErr: context.DeadlineExceeded, wantResult: "error"},
{name: "invalid project", results: []projectstatus.Result{{Degree: entity.DegreeGreen}}, invalid: true, wantResult: "error"},
{name: "invalid degree", results: []projectstatus.Result{{Project: "project-a", Degree: math.NaN()}}, invalid: true, wantResult: "error"},
{name: "invalid degree", results: []projectstatus.Result{{ProjectInfo: projectstatus.ProjectInfo{Name: "project-a"}, Degree: math.NaN()}}, invalid: true, wantResult: "error"},
{name: "duplicate projects", results: []projectstatus.Result{validResults[0], validResults[0]}, invalid: true, wantResult: "error"},
} {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -509,14 +509,14 @@ func TestPersistProjectFactsMetrics(t *testing.T) {
var results []projectstatus.Result
if !tt.empty {
results = []projectstatus.Result{
{Project: "project-a", Degree: entity.DegreeGreen},
{Project: "project-b", Degree: entity.DegreeBroken},
{ProjectInfo: projectstatus.ProjectInfo{Name: "project-a"}, Degree: entity.DegreeGreen},
{ProjectInfo: projectstatus.ProjectInfo{Name: "project-b"}, Degree: entity.DegreeBroken},
}
}
materializer := requestlogmock.NewMockMaterializer(ctrl)
c.materializer = materializer
for _, result := range results {
fact := entity.ValidationFact{URI: request.URI, Project: result.Project, Degree: result.Degree, RequestID: request.ID, CreatedAt: testChangeTime.UnixMilli()}
fact := entity.ValidationFact{URI: request.URI, Project: result.ProjectInfo.Name, Degree: result.Degree, RequestID: request.ID, CreatedAt: testChangeTime.UnixMilli()}
createErr := tt.factErr
if tt.existing {
createErr = storage.ErrAlreadyExists
Expand All @@ -527,7 +527,7 @@ func TestPersistProjectFactsMetrics(t *testing.T) {
}
indexErr := tt.indexErr
if tt.existing {
m.factStore.EXPECT().Get(gomock.Any(), request.URI, result.Project).Return(fact, nil)
m.factStore.EXPECT().Get(gomock.Any(), request.URI, result.ProjectInfo.Name).Return(fact, nil)
indexErr = storage.ErrAlreadyExists
}
entry, err := entity.NewProjectFactByDegree(fact)
Expand Down Expand Up @@ -641,8 +641,8 @@ func TestProcess_RejectsInvalidProjectStatusResultsBeforeWritingFacts(t *testing
projectStatusResolverFactory.EXPECT().For(projectstatus.ResolverConfig{QueueName: testQueue}).
Return(projectStatusResolver, nil)
projectStatusResolver.EXPECT().Resolve(gomock.Any(), gomock.Any(), gomock.Any()).Return([]projectstatus.Result{
{Project: "project-a", Degree: entity.DegreeBroken},
{Project: "project-b", Degree: math.NaN()},
{ProjectInfo: projectstatus.ProjectInfo{Name: "project-a"}, Degree: entity.DegreeBroken},
{ProjectInfo: projectstatus.ProjectInfo{Name: "project-b"}, Degree: math.NaN()},
}, nil)
m.reqStore.EXPECT().Get(gomock.Any(), testID).Return(requestWithState(entity.RequestStateFailed), nil)

Expand Down Expand Up @@ -1388,8 +1388,8 @@ func TestPersistProjectFactsRepairsIndexOnRetry(t *testing.T) {
return nil
}),
)
require.ErrorIs(t, c.persistProjectFacts(queueContext(), m.store, request, []projectstatus.Result{{Project: "p", Degree: 0.25}}), writeErr)
require.NoError(t, c.persistProjectFacts(queueContext(), m.store, request, []projectstatus.Result{{Project: "p", Degree: 0.75}}))
require.ErrorIs(t, c.persistProjectFacts(queueContext(), m.store, request, []projectstatus.Result{{ProjectInfo: projectstatus.ProjectInfo{Name: "p"}, Degree: 0.25}}), writeErr)
require.NoError(t, c.persistProjectFacts(queueContext(), m.store, request, []projectstatus.Result{{ProjectInfo: projectstatus.ProjectInfo{Name: "p"}, Degree: 0.75}}))
snapshot := m.metricsScope.Snapshot()
assert.EqualValues(t, 2, snapshot.Counters()["record_controller.persist_project_facts.start+queue="+testQueue].Value())
for _, result := range []string{"success", "error"} {
Expand All @@ -1411,9 +1411,9 @@ func TestProcessRecordsDegreeAfterResolvingProjects(t *testing.T) {
{name: "successful repository", state: entity.RequestStateSucceeded, degree: entity.DegreeGreen},
{name: "failed repository without project results", state: entity.RequestStateFailed, degree: entity.DegreeBroken},
{name: "failed repository with project results", state: entity.RequestStateFailed, degree: entity.DegreePartial,
results: []projectstatus.Result{{Project: "a", Degree: entity.DegreeGreen}, {Project: "b", Degree: 0.25}}},
results: []projectstatus.Result{{ProjectInfo: projectstatus.ProjectInfo{Name: "a", ProjectType: "service"}, Degree: entity.DegreeGreen}, {ProjectInfo: projectstatus.ProjectInfo{Name: "b", ProjectType: "batch"}, Degree: 0.25}}},
{name: "successful repository with project results", state: entity.RequestStateSucceeded, degree: entity.DegreeGreen,
results: []projectstatus.Result{{Project: "a", Degree: entity.DegreeGreen}}},
results: []projectstatus.Result{{ProjectInfo: projectstatus.ProjectInfo{Name: "a", ProjectType: "service"}, Degree: entity.DegreeGreen}}},
} {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
Expand Down Expand Up @@ -1442,7 +1442,7 @@ func TestProcessRecordsDegreeAfterResolvingProjects(t *testing.T) {
}).Times(len(tt.results) + 1)
for _, result := range tt.results {
m.indexStore.EXPECT().Create(gomock.Any(), entity.ProjectFactByDegree{
URI: testURI, Degree: result.Degree, Project: result.Project,
URI: testURI, Degree: result.Degree, Project: result.ProjectInfo.Name,
}).Return(nil)
}
if tt.degree == entity.DegreeGreen {
Expand All @@ -1457,7 +1457,8 @@ func TestProcessRecordsDegreeAfterResolvingProjects(t *testing.T) {
require.NoError(t, c.Process(queueContext(), delivery(t, ctrl, recordPayload(t, testID))))
assert.Equal(t, tt.degree, facts[""].Degree)
for _, result := range tt.results {
assert.Equal(t, result.Degree, facts[result.Project].Degree)
assert.Equal(t, result.Degree, facts[result.ProjectInfo.Name].Degree)
assert.Equal(t, result.ProjectInfo.ProjectType, facts[result.ProjectInfo.Name].ProjectType)
}
assert.Len(t, m.hooks.events, 1)
})
Expand All @@ -1477,7 +1478,7 @@ func TestProcessRetriesProjectPersistenceWithImmutablePartialFact(t *testing.T)
c.projectStatusResolverFactory = factory
factory.EXPECT().For(projectstatus.ResolverConfig{QueueName: testQueue}).Return(resolver, nil).Times(2)
resolver.EXPECT().Resolve(gomock.Any(), request, request.TerminalBuildID).Return(
[]projectstatus.Result{{Project: "a", Degree: entity.DegreeBroken}}, nil).Times(2)
[]projectstatus.Result{{ProjectInfo: projectstatus.ProjectInfo{Name: "a"}, Degree: entity.DegreeBroken}}, nil).Times(2)
var stored entity.ValidationFact
m.factStore.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, fact entity.ValidationFact) error {
assert.Empty(t, fact.Project)
Expand Down Expand Up @@ -1536,7 +1537,7 @@ func TestPersistProjectFactsCountsPersistedOutcomes(t *testing.T) {
}, log.Metadata)
return nil
})
require.NoError(t, c.persistProjectFacts(queueContext(), m.store, request, []projectstatus.Result{{Project: "p", Degree: tt.resolvedDegree}}))
require.NoError(t, c.persistProjectFacts(queueContext(), m.store, request, []projectstatus.Result{{ProjectInfo: projectstatus.ProjectInfo{Name: "p"}, Degree: tt.resolvedDegree}}))
})
}
}
2 changes: 2 additions & 0 deletions stovepipe/entity/validation_fact.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ type ValidationFact struct {
// Project is the scope the degree applies to. Empty means the whole
// repository; named projects narrow the fact to part of it.
Project string `json:"project"`
// ProjectType is a provider-defined classification recorded with the fact; empty means unknown.
ProjectType string `json:"project_type"`
// Degree is how broken the scope was, on the closed interval
// [DegreeGreen, DegreeBroken]. A failed repository with project results has
// DegreePartial; without project results it has DegreeBroken.
Expand Down
15 changes: 11 additions & 4 deletions stovepipe/extension/projectstatus/projectstatus.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,18 @@ import (
"github.com/uber/submitqueue/stovepipe/entity"
)

// Result is one project status result. The record stage supplies the request
// identity and recording timestamp when it persists this result.
// ProjectInfo identifies a project and its provider-defined metadata.
type ProjectInfo struct {
// Name identifies the project within its repository.
Name string
// ProjectType is a provider-defined classification; empty means unknown.
ProjectType string
}

// Result is one project status result.
type Result struct {
// Project identifies the project to which this result applies.
Project string
// ProjectInfo identifies and describes the project.
ProjectInfo ProjectInfo
// Degree describes how broken the project is, on the closed interval [0, 1].
Degree float64
}
Expand Down
6 changes: 6 additions & 0 deletions stovepipe/extension/storage/mysql/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Schema upgrades

Before deploying application code that reads or writes project types, existing databases
must add `project_type` as the last column in `validation_fact`, with type `VARCHAR(255)`,
`NOT NULL`, and an empty-string default. Existing facts retain an unknown project type;
their immutable recorded results are preserved. Fresh databases use `schema/`.
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ CREATE TABLE IF NOT EXISTS validation_fact (
degree DOUBLE NOT NULL,
request_id VARCHAR(255) NOT NULL,
created_at BIGINT NOT NULL,
project_type VARCHAR(255) NOT NULL DEFAULT '',
PRIMARY KEY (queue, uri, project)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
12 changes: 7 additions & 5 deletions stovepipe/extension/storage/mysql/validation_fact_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,12 @@ func (v *validationFactStore) Create(ctx context.Context, fact entity.Validation

queue := v.queue
_, err := v.db.ExecContext(ctx,
`INSERT INTO validation_fact (queue, uri, project, degree, request_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
`INSERT INTO validation_fact (queue, uri, project, project_type, degree, request_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
queue,
fact.URI,
fact.Project,
fact.ProjectType,
fact.Degree,
fact.RequestID,
fact.CreatedAt,
Expand All @@ -74,11 +75,12 @@ func (v *validationFactStore) Get(ctx context.Context, uri, project string) (ret
queue := v.queue
var fact entity.ValidationFact
err := v.db.QueryRowContext(ctx,
"SELECT uri, project, degree, request_id, created_at FROM validation_fact WHERE queue = ? AND uri = ? AND project = ?",
"SELECT uri, project, project_type, degree, request_id, created_at FROM validation_fact WHERE queue = ? AND uri = ? AND project = ?",
queue, uri, project,
).Scan(
&fact.URI,
&fact.Project,
&fact.ProjectType,
&fact.Degree,
&fact.RequestID,
&fact.CreatedAt,
Expand All @@ -102,7 +104,7 @@ func (v *validationFactStore) ListProjects(ctx context.Context, uri, afterProjec
return nil, fmt.Errorf("validation fact list limit must be positive")
}
rows, err := v.db.QueryContext(ctx, `
SELECT uri, project, degree, request_id, created_at FROM validation_fact
SELECT uri, project, project_type, degree, request_id, created_at FROM validation_fact
WHERE queue = ? AND uri = ? AND project > ?
ORDER BY project ASC LIMIT ?`,
v.queue, uri, afterProject, limit,
Expand All @@ -114,7 +116,7 @@ func (v *validationFactStore) ListProjects(ctx context.Context, uri, afterProjec

for rows.Next() {
var fact entity.ValidationFact
if err := rows.Scan(&fact.URI, &fact.Project, &fact.Degree, &fact.RequestID, &fact.CreatedAt); err != nil {
if err := rows.Scan(&fact.URI, &fact.Project, &fact.ProjectType, &fact.Degree, &fact.RequestID, &fact.CreatedAt); err != nil {
return nil, fmt.Errorf("failed to scan project fact: %w", err)
}
ret = append(ret, fact)
Expand Down
Loading
Loading