From b5f9051f9e434bb0c0b01dff68b149c5116ec593 Mon Sep 17 00:00:00 2001 From: "prath.shenoy" Date: Mon, 21 Sep 2026 19:52:23 +0000 Subject: [PATCH] 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. --- Makefile | 2 +- doc/rfc/stovepipe/request-log.md | 9 +- service/stovepipe/server/BUILD.bazel | 1 + service/stovepipe/server/main.go | 3 +- stovepipe/controller/record/BUILD.bazel | 4 + stovepipe/controller/record/record.go | 134 ++++++++++++++---- stovepipe/controller/record/record_test.go | 74 ++++++++++ stovepipe/core/requestlog/materializer.go | 2 + stovepipe/entity/request_log.go | 4 +- stovepipe/entity/request_log_test.go | 10 ++ stovepipe/extension/projectresult/BUILD.bazel | 9 ++ .../extension/projectresult/mock/BUILD.bazel | 13 ++ .../projectresult/mock/projectresult_mock.go | 97 +++++++++++++ .../extension/projectresult/noop/BUILD.bazel | 23 +++ .../extension/projectresult/noop/noop.go | 46 ++++++ .../extension/projectresult/noop/noop_test.go | 33 +++++ .../extension/projectresult/projectresult.go | 52 +++++++ 17 files changed, 480 insertions(+), 36 deletions(-) create mode 100644 stovepipe/extension/projectresult/BUILD.bazel create mode 100644 stovepipe/extension/projectresult/mock/BUILD.bazel create mode 100644 stovepipe/extension/projectresult/mock/projectresult_mock.go create mode 100644 stovepipe/extension/projectresult/noop/BUILD.bazel create mode 100644 stovepipe/extension/projectresult/noop/noop.go create mode 100644 stovepipe/extension/projectresult/noop/noop_test.go create mode 100644 stovepipe/extension/projectresult/projectresult.go diff --git a/Makefile b/Makefile index b15be5161..760f3f681 100644 --- a/Makefile +++ b/Makefile @@ -579,7 +579,7 @@ local-stovepipe-stop: ## Stop the Stovepipe service mocks: ## Generate mock files using mockgen @echo "Generating mocks..." - @$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/consumergate/... ./platform/extension/hook/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./runway/extension/merger/... ./submitqueue/extension/conflict/... ./submitqueue/extension/speculation/... ./submitqueue/extension/validator/... ./platform/consumer/... ./stovepipe/core/requestlog/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/... + @$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/consumergate/... ./platform/extension/hook/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./runway/extension/merger/... ./submitqueue/extension/conflict/... ./submitqueue/extension/speculation/... ./submitqueue/extension/validator/... ./platform/consumer/... ./stovepipe/core/requestlog/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/... ./stovepipe/extension/projectresult/... @echo "Mocks generated successfully!" proto: ## Generate protobuf files from .proto definitions diff --git a/doc/rfc/stovepipe/request-log.md b/doc/rfc/stovepipe/request-log.md index 4d866cf88..761159c67 100644 --- a/doc/rfc/stovepipe/request-log.md +++ b/doc/rfc/stovepipe/request-log.md @@ -7,6 +7,7 @@ Stovepipe retains an append-only request log for each validation request. Its in - `build_triggered`; - `build_finished`; - `validation_fact_recorded`; +- `project_facts_recorded`; - `record_abandoned`. The model deliberately follows SubmitQueue's distinction between statuses describing where a request is and events describing important activity that does not move it. It remains a bounded request-lifecycle log rather than a generic event bus or an audit of every correlated operation. @@ -32,7 +33,7 @@ The following remain with their owning entities, metrics, or structured logs: - Queue latest-request and last-green bookmarks; - source-control promotion; -- project analysis and project facts; +- project analysis and individual project facts; - hooks and downstream notifications; - build-slot claims, waits, and releases; - queue handoffs, delivery attempts, holds, nacks, and visibility timeouts; @@ -123,9 +124,10 @@ Immutable Request context such as URI, build strategy, and base URI remains on ` | `build_triggered` | A runner accepted a build and its Build row became durable. | Build ID metadata and creation time | | `build_finished` | The Build first reached a write-once terminal status. | Build ID metadata and status-change time | | `validation_fact_recorded` | The immutable whole-repository fact became durable. | Degree metadata and fact creation time | +| `project_facts_recorded` | All project validation facts returned for a request became durable. | Project-fact count metadata and completion time | | `record_abandoned` | Record-stage work stopped after exhausting primary retries. | Event retention time | -Build running and unchanged polls are not retained. Trigger and terminal result explain the request outcome without turning polling into an unbounded log. Project facts remain outside the initial vocabulary. +Build running and unchanged polls are not retained. Trigger and terminal result explain the request outcome without turning polling into an unbounded log. Individual project facts remain outside the vocabulary; one batch-completion event summarizes their durable recording. ### Evolution @@ -157,6 +159,7 @@ SubmitQueue applies that identity to the message carrying a log to its materiali | Build triggered | Request ID, event kind, and build ID | | Build finished | Request ID, event kind, and build ID | | Validation fact recorded | Request ID, event kind, and whole-repository fact identity | +| Project facts recorded | Request ID, event kind, and the completed project-fact batch | The controller passes the materializer the same queue-scoped storage aggregate used for the source write. The materializer preserves a supplied occurrence time or assigns the current time immediately before the first insertion attempt, then calls `RequestLogStore.Create`. If the ID already exists, it loads the stored record and compares the explicitly designated stable semantic fields. The first successfully retained timestamp is authoritative and is not compared with a later retry's newly sampled time. Metadata keys emitted by both records must agree, while a key present on only one record remains compatible so an additive metadata rollout does not turn retries of older occurrences into conflicts. Compatible content is idempotent success; conflicting content is an internal consistency error, and the stored record is never overwritten or enriched. @@ -209,7 +212,7 @@ Request creation, Build changes, and fact creation use the same source-write, lo | Process | CAS to superseded or processing, then retain that state. | An existing state is reconstructed from Request context before ack or build publication. | | Build | Create Build after runner acceptance, then retain `build_triggered`. | An identical existing Build ensures the event before buildsignal publication. | | Buildsignal | Persist terminal Build and retain `build_finished`; CAS the Request outcome and retain its terminal state. | Existing terminal Build and Request outcome each ensure their own entry before record publication. | -| Record | Create or verify the whole-repository fact, then retain `validation_fact_recorded`. | An identical fact owned by the Request ensures the event before bookmark or promotion work. | +| Record | Create or verify the whole-repository fact, then retain `validation_fact_recorded`; after recording project facts, retain one `project_facts_recorded` event. | An identical whole-repository fact and project-fact batch ensure their events before bookmark or promotion work. | | Record DLQ | Retain `record_abandoned`, then acknowledge the remaining record work. | The stable event ID makes history retention idempotent without replaying facts, bookmarks, promotion, or hooks. | | Reconciler | CAS an unrecoverable non-terminal Request to failed, then retain failed. | An existing terminal Request is repaired from its persisted outcome without relabeling it. | diff --git a/service/stovepipe/server/BUILD.bazel b/service/stovepipe/server/BUILD.bazel index 3e08d5e93..31223f030 100644 --- a/service/stovepipe/server/BUILD.bazel +++ b/service/stovepipe/server/BUILD.bazel @@ -33,6 +33,7 @@ go_library( "//stovepipe/core/requestlog:go_default_library", "//stovepipe/extension/buildrunner:go_default_library", "//stovepipe/extension/buildrunner/fake:go_default_library", + "//stovepipe/extension/projectresult/noop:go_default_library", "//stovepipe/extension/queueconfig/default:go_default_library", "//stovepipe/extension/sourcecontrol:go_default_library", "//stovepipe/extension/sourcecontrol/fake:go_default_library", diff --git a/service/stovepipe/server/main.go b/service/stovepipe/server/main.go index cfb5b02c0..8abec0ba5 100644 --- a/service/stovepipe/server/main.go +++ b/service/stovepipe/server/main.go @@ -55,6 +55,7 @@ import ( "github.com/uber/submitqueue/stovepipe/core/requestlog" "github.com/uber/submitqueue/stovepipe/extension/buildrunner" buildrunnerfake "github.com/uber/submitqueue/stovepipe/extension/buildrunner/fake" + projectresultnoop "github.com/uber/submitqueue/stovepipe/extension/projectresult/noop" queueconfigdefault "github.com/uber/submitqueue/stovepipe/extension/queueconfig/default" "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol" sourcecontrolfake "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol/fake" @@ -440,7 +441,7 @@ func registerPrimaryControllers( } count++ - recordController := record.NewController(logger, scope, store, materializer, sourceControl, registry, stovepipemq.TopicKeyRecord, "stovepipe-record") + recordController := record.NewController(logger, scope, store, materializer, projectresultnoop.New(), sourceControl, registry, stovepipemq.TopicKeyRecord, "stovepipe-record") if err := c.Register(recordController); err != nil { return count, fmt.Errorf("failed to register record controller: %w", err) } diff --git a/stovepipe/controller/record/BUILD.bazel b/stovepipe/controller/record/BUILD.bazel index 17fbb8b85..f5eed3d46 100644 --- a/stovepipe/controller/record/BUILD.bazel +++ b/stovepipe/controller/record/BUILD.bazel @@ -19,6 +19,7 @@ go_library( "//stovepipe/core/messagequeue:go_default_library", "//stovepipe/core/requestlog:go_default_library", "//stovepipe/entity:go_default_library", + "//stovepipe/extension/projectresult:go_default_library", "//stovepipe/extension/sourcecontrol:go_default_library", "//stovepipe/extension/storage:go_default_library", "@com_github_uber_go_tally//:go_default_library", @@ -46,6 +47,9 @@ go_test( "//stovepipe/core/requestlog:go_default_library", "//stovepipe/core/requestlog/mock:go_default_library", "//stovepipe/entity:go_default_library", + "//stovepipe/extension/projectresult:go_default_library", + "//stovepipe/extension/projectresult/mock:go_default_library", + "//stovepipe/extension/projectresult/noop:go_default_library", "//stovepipe/extension/sourcecontrol:go_default_library", "//stovepipe/extension/sourcecontrol/mock:go_default_library", "//stovepipe/extension/storage:go_default_library", diff --git a/stovepipe/controller/record/record.go b/stovepipe/controller/record/record.go index 38fc8e71b..e5ca19c6b 100644 --- a/stovepipe/controller/record/record.go +++ b/stovepipe/controller/record/record.go @@ -33,6 +33,7 @@ import ( "encoding/hex" "errors" "fmt" + "math" "strconv" "time" @@ -47,6 +48,7 @@ import ( stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue" "github.com/uber/submitqueue/stovepipe/core/requestlog" "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/projectresult" "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol" "github.com/uber/submitqueue/stovepipe/extension/storage" "go.uber.org/zap" @@ -56,14 +58,15 @@ import ( // when that fact is green advances the queue's last-green bookmark and promotes // the commit. Implements consumer.Controller. type Controller struct { - logger *zap.SugaredLogger - metricsScope tally.Scope - stores storage.Factory - materializer requestlog.Materializer - sourceControl sourcecontrol.Factory - registry consumer.TopicRegistry - topicKey consumer.TopicKey - consumerGroup string + logger *zap.SugaredLogger + metricsScope tally.Scope + stores storage.Factory + materializer requestlog.Materializer + projectResultFactory projectresult.Factory + sourceControl sourcecontrol.Factory + registry consumer.TopicRegistry + topicKey consumer.TopicKey + consumerGroup string } // Verify Controller implements consumer.Controller interface at compile time. @@ -73,8 +76,7 @@ var _ consumer.Controller = (*Controller)(nil) const _opName = "record" // wholeRepositoryProject is the project component of a fact covering the whole -// repository rather than one project within it. Per-project facts need target-graph -// attribution that this stage does not do, so every fact it writes is whole-repository. +// repository rather than one project within it. const wholeRepositoryProject = "" // NewController creates a new record controller. @@ -83,6 +85,7 @@ func NewController( scope tally.Scope, stores storage.Factory, materializer requestlog.Materializer, + projectResultFactory projectresult.Factory, sourceControl sourcecontrol.Factory, registry consumer.TopicRegistry, topicKey consumer.TopicKey, @@ -90,14 +93,15 @@ func NewController( ) *Controller { name := string(topicKey) + "_controller" return &Controller{ - logger: logger.Named(name), - metricsScope: scope.SubScope(name), - stores: stores, - materializer: materializer, - sourceControl: sourceControl, - registry: registry, - topicKey: topicKey, - consumerGroup: consumerGroup, + logger: logger.Named(name), + metricsScope: scope.SubScope(name), + stores: stores, + materializer: materializer, + projectResultFactory: projectResultFactory, + sourceControl: sourceControl, + registry: registry, + topicKey: topicKey, + consumerGroup: consumerGroup, } } @@ -150,6 +154,9 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er if err := c.persistValidationFactRecordedLog(ctx, store, request, fact); err != nil { return err } + if err := c.recordProjectFacts(ctx, store, request); err != nil { + return err + } if err := c.applyFactToDerivedCaches(ctx, store, request, fact, created); err != nil { return err } @@ -176,6 +183,72 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er } } +func (c *Controller) recordProjectFacts(ctx context.Context, store storage.Storage, request entity.Request) error { + resolver, err := c.projectResultFactory.For(projectresult.Config{QueueName: request.Queue}) + if err != nil { + return fmt.Errorf("failed to resolve project result resolver for queue %q: %w", request.Queue, err) + } + results, err := resolver.Resolve(ctx, request) + if err != nil { + return fmt.Errorf("failed to resolve project results for request %q: %w", request.ID, err) + } + + if err := validateProjectResults(request, results); err != nil { + return err + } + for _, result := range results { + _, _, err := c.recordValidationFact(ctx, store.GetValidationFactStore(), entity.ValidationFact{ + URI: request.URI, + Project: result.Project, + Degree: result.Degree, + RequestID: request.ID, + CreatedAt: time.Now().UnixMilli(), + }) + if err != nil { + return err + } + } + if len(results) == 0 { + return nil + } + return c.persistProjectFactsRecordedLog(ctx, store, request, len(results)) +} + +func validateProjectResults(request entity.Request, results []projectresult.Result) error { + seen := make(map[string]struct{}, len(results)) + for _, result := range results { + if result.Project == "" { + return fmt.Errorf("project result for request %q has an empty project", request.ID) + } + if _, ok := seen[result.Project]; ok { + return fmt.Errorf("project result for request %q contains duplicate project %q", request.ID, result.Project) + } + seen[result.Project] = struct{}{} + if math.IsNaN(result.Degree) || result.Degree < entity.DegreeGreen || result.Degree > entity.DegreeBroken { + return fmt.Errorf("project result for request %q and project %q has invalid degree %v", request.ID, result.Project, result.Degree) + } + } + return nil +} + +func (c *Controller) persistProjectFactsRecordedLog( + ctx context.Context, + store storage.Storage, + request entity.Request, + count int, +) error { + log := requestlog.NewRequestEventLog( + request, + entity.RequestEventProjectFactsRecorded, + "all", + map[string]string{requestlog.MetadataKeyProjectFactCount: strconv.Itoa(count)}, + ) + if err := c.materializer.PersistLog(ctx, store, log); err != nil { + return fmt.Errorf("failed to record project facts for request %s: %w", request.ID, err) + } + return nil +} + func (c *Controller) persistValidationFactRecordedLog( ctx context.Context, store storage.Storage, @@ -241,48 +314,49 @@ func (c *Controller) applyFactToDerivedCaches( // second return reports whether this call is the one that wrote the fact, which is // how a caller tells the original delivery from a redelivery. func (c *Controller) recordFact(ctx context.Context, store storage.Storage, request entity.Request) (entity.ValidationFact, bool, error) { - factStore := store.GetValidationFactStore() - - fact := entity.ValidationFact{ + return c.recordValidationFact(ctx, store.GetValidationFactStore(), entity.ValidationFact{ URI: request.URI, Project: wholeRepositoryProject, Degree: degreeFor(request.State), RequestID: request.ID, CreatedAt: time.Now().UnixMilli(), - } + }) +} + +func (c *Controller) recordValidationFact(ctx context.Context, factStore storage.ValidationFactStore, fact entity.ValidationFact) (entity.ValidationFact, bool, error) { err := factStore.Create(ctx, fact) switch { case err == nil: metrics.NamedCounter(c.metricsScope, _opName, "fact_created", 1, metrics.TagsFromContext(ctx)...) c.logger.Infow("recorded validation fact", - "queue", request.Queue, - "request_id", request.ID, - "uri", request.URI, + "request_id", fact.RequestID, + "uri", fact.URI, + "project", fact.Project, "degree", fact.Degree, ) return fact, true, nil case errors.Is(err, storage.ErrAlreadyExists): - stored, getErr := factStore.Get(ctx, request.URI, wholeRepositoryProject) + stored, getErr := factStore.Get(ctx, fact.URI, fact.Project) if getErr != nil { metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1, metrics.TagsFromContext(ctx)...) - return entity.ValidationFact{}, false, fmt.Errorf("failed to load the existing fact for uri %s: %w", request.URI, getErr) + return entity.ValidationFact{}, false, fmt.Errorf("failed to load the existing fact for uri %s and project %q: %w", fact.URI, fact.Project, getErr) } - if stored.RequestID != request.ID { + if stored.RequestID != fact.RequestID { // Two requests validating one URI would break the dedup ingest // enforces, so this is a broken invariant rather than a race to // resolve. Non-retryable: the stored fact is immutable. metrics.NamedCounter(c.metricsScope, _opName, "invariant_errors", 1, metrics.TagsFromContext(ctx)...) return entity.ValidationFact{}, false, fmt.Errorf( - "fact for uri %s is owned by request %s, not %s", request.URI, stored.RequestID, request.ID) + "fact for uri %s and project %q is owned by request %s, not %s", fact.URI, fact.Project, stored.RequestID, fact.RequestID) } metrics.NamedCounter(c.metricsScope, _opName, "fact_exists", 1, metrics.TagsFromContext(ctx)...) return stored, false, nil default: metrics.NamedCounter(c.metricsScope, _opName, "storage_errors", 1, metrics.TagsFromContext(ctx)...) - return entity.ValidationFact{}, false, fmt.Errorf("failed to create the fact for uri %s: %w", request.URI, err) + return entity.ValidationFact{}, false, fmt.Errorf("failed to create the fact for uri %s and project %q: %w", fact.URI, fact.Project, err) } } diff --git a/stovepipe/controller/record/record_test.go b/stovepipe/controller/record/record_test.go index 887f93b1c..b5f0681ed 100644 --- a/stovepipe/controller/record/record_test.go +++ b/stovepipe/controller/record/record_test.go @@ -17,6 +17,7 @@ package record import ( "context" "errors" + "math" "testing" "time" @@ -34,6 +35,9 @@ import ( "github.com/uber/submitqueue/stovepipe/core/requestlog" requestlogmock "github.com/uber/submitqueue/stovepipe/core/requestlog/mock" "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/projectresult" + projectresultmock "github.com/uber/submitqueue/stovepipe/extension/projectresult/mock" + projectresultnoop "github.com/uber/submitqueue/stovepipe/extension/projectresult/noop" "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol" sourcecontrolmock "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol/mock" "github.com/uber/submitqueue/stovepipe/extension/storage" @@ -194,6 +198,7 @@ func newControllerForTopic(t *testing.T, ctrl *gomock.Controller, topicKey consu scope, staticStorageFactory{store: m.store}, m.materializer, + projectresultnoop.New(), staticSourceControlFactory{sourceControl: m.sourceControl}, registry, topicKey, @@ -325,6 +330,75 @@ func TestProcess_AdvancesBookmarkOnSuccess(t *testing.T) { } } +func TestProcess_RecordsNamedProjectResults(t *testing.T) { + ctrl := gomock.NewController(t) + c, m := newController(t, ctrl) + eventMaterializer := requestlogmock.NewMockMaterializer(ctrl) + c.materializer = eventMaterializer + projectResultFactory := projectresultmock.NewMockFactory(ctrl) + projectResultResolver := projectresultmock.NewMockResolver(ctrl) + c.projectResultFactory = projectResultFactory + projectResultFactory.EXPECT().For(projectresult.Config{QueueName: testQueue}). + Return(projectResultResolver, nil) + projectResultResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return([]projectresult.Result{ + {Project: "project-a", Degree: entity.DegreeBroken}, + {Project: "project-b", Degree: entity.DegreeBroken}, + }, nil) + m.reqStore.EXPECT().Get(gomock.Any(), testID).Return(requestWithState(entity.RequestStateFailed), nil) + + var facts []entity.ValidationFact + m.factStore.EXPECT().Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, fact entity.ValidationFact) error { + facts = append(facts, fact) + return nil + }). + Times(3) + + var logs []entity.RequestLog + eventMaterializer.EXPECT().PersistLog(gomock.Any(), m.store, gomock.Any()). + DoAndReturn(func(_ context.Context, _ storage.Storage, log entity.RequestLog) error { + logs = append(logs, log) + return nil + }). + Times(2) + + require.NoError(t, c.Process(queueContext(), delivery(t, ctrl, recordPayload(t, testID)))) + require.Len(t, facts, 3) + assert.Equal(t, []string{"", "project-a", "project-b"}, []string{facts[0].Project, facts[1].Project, facts[2].Project}) + for _, fact := range facts { + assert.Equal(t, testURI, fact.URI) + assert.Equal(t, testID, fact.RequestID) + assert.Equal(t, entity.DegreeBroken, fact.Degree) + assert.Positive(t, fact.CreatedAt) + } + require.Len(t, logs, 2) + assert.Equal(t, entity.RequestEventValidationFactRecorded, logs[0].Event) + assert.Equal(t, entity.RequestEventProjectFactsRecorded, logs[1].Event) + assert.Equal(t, "2", logs[1].Metadata[requestlog.MetadataKeyProjectFactCount]) +} + +func TestProcess_RejectsInvalidProjectResultsBeforeWritingFacts(t *testing.T) { + ctrl := gomock.NewController(t) + c, m := newController(t, ctrl) + projectResultFactory := projectresultmock.NewMockFactory(ctrl) + projectResultResolver := projectresultmock.NewMockResolver(ctrl) + c.projectResultFactory = projectResultFactory + projectResultFactory.EXPECT().For(projectresult.Config{QueueName: testQueue}). + Return(projectResultResolver, nil) + projectResultResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return([]projectresult.Result{ + {Project: "project-a", Degree: entity.DegreeBroken}, + {Project: "project-b", Degree: math.NaN()}, + }, nil) + m.reqStore.EXPECT().Get(gomock.Any(), testID).Return(requestWithState(entity.RequestStateFailed), nil) + var fact entity.ValidationFact + m.expectFactCreated(&fact) + + err := c.Process(queueContext(), delivery(t, ctrl, recordPayload(t, testID))) + require.Error(t, err) + assert.ErrorContains(t, err, "invalid degree") + assert.Equal(t, wholeRepositoryProject, fact.Project) +} + func TestProcess_TimestampReportingFailureDoesNotFailRecord(t *testing.T) { tests := []struct { name string diff --git a/stovepipe/core/requestlog/materializer.go b/stovepipe/core/requestlog/materializer.go index 59d862260..bda105348 100644 --- a/stovepipe/core/requestlog/materializer.go +++ b/stovepipe/core/requestlog/materializer.go @@ -40,6 +40,8 @@ const ( MetadataKeyBuildID = "build_id" // MetadataKeyFactDegree records the degree established by a validation fact. MetadataKeyFactDegree = "fact_degree" + // MetadataKeyProjectFactCount records how many project facts a completed batch established. + MetadataKeyProjectFactCount = "project_fact_count" // MetadataKeyBaseURI records the selected build baseline; an empty value means a full build. MetadataKeyBaseURI = "base_uri" ) diff --git a/stovepipe/entity/request_log.go b/stovepipe/entity/request_log.go index 43bf4bd9b..321a3061a 100644 --- a/stovepipe/entity/request_log.go +++ b/stovepipe/entity/request_log.go @@ -28,6 +28,8 @@ const ( RequestEventBuildFinished RequestEvent = "build_finished" // RequestEventValidationFactRecorded records that an immutable validation verdict was established. RequestEventValidationFactRecorded RequestEvent = "validation_fact_recorded" + // RequestEventProjectFactsRecorded records that all project validation facts for a request were established. + RequestEventProjectFactsRecorded RequestEvent = "project_facts_recorded" // RequestEventRecordAbandoned records that record-stage work stopped after exhausting retries. RequestEventRecordAbandoned RequestEvent = "record_abandoned" ) @@ -135,7 +137,7 @@ func (e RequestLog) validateEvent() error { return fmt.Errorf("event log must not contain request-state context") } switch e.Event { - case RequestEventBuildTriggered, RequestEventBuildFinished, RequestEventValidationFactRecorded, RequestEventRecordAbandoned: + case RequestEventBuildTriggered, RequestEventBuildFinished, RequestEventValidationFactRecorded, RequestEventProjectFactsRecorded, RequestEventRecordAbandoned: default: return fmt.Errorf("unknown request event %q", e.Event) } diff --git a/stovepipe/entity/request_log_test.go b/stovepipe/entity/request_log_test.go index 4dd57ac9e..9dc4daaf5 100644 --- a/stovepipe/entity/request_log_test.go +++ b/stovepipe/entity/request_log_test.go @@ -106,6 +106,16 @@ func TestRequestLogValidate(t *testing.T) { return entry }, }, + { + name: "project facts recorded", + mutate: func(entry RequestLog) RequestLog { + entry.State = RequestStateUnknown + entry.Event = RequestEventProjectFactsRecorded + entry.RequestVersion = 0 + entry.Metadata = map[string]string{"project_fact_count": "2"} + return entry + }, + }, { name: "build event", mutate: func(entry RequestLog) RequestLog { diff --git a/stovepipe/extension/projectresult/BUILD.bazel b/stovepipe/extension/projectresult/BUILD.bazel new file mode 100644 index 000000000..601806570 --- /dev/null +++ b/stovepipe/extension/projectresult/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["projectresult.go"], + importpath = "github.com/uber/submitqueue/stovepipe/extension/projectresult", + visibility = ["//visibility:public"], + deps = ["//stovepipe/entity:go_default_library"], +) diff --git a/stovepipe/extension/projectresult/mock/BUILD.bazel b/stovepipe/extension/projectresult/mock/BUILD.bazel new file mode 100644 index 000000000..057fc4f16 --- /dev/null +++ b/stovepipe/extension/projectresult/mock/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["projectresult_mock.go"], + importpath = "github.com/uber/submitqueue/stovepipe/extension/projectresult/mock", + visibility = ["//visibility:public"], + deps = [ + "//stovepipe/entity:go_default_library", + "//stovepipe/extension/projectresult:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + ], +) diff --git a/stovepipe/extension/projectresult/mock/projectresult_mock.go b/stovepipe/extension/projectresult/mock/projectresult_mock.go new file mode 100644 index 000000000..a3a0f12a8 --- /dev/null +++ b/stovepipe/extension/projectresult/mock/projectresult_mock.go @@ -0,0 +1,97 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: projectresult.go +// +// Generated by this command: +// +// mockgen -source=projectresult.go -destination=mock/projectresult_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + entity "github.com/uber/submitqueue/stovepipe/entity" + projectresult "github.com/uber/submitqueue/stovepipe/extension/projectresult" + gomock "go.uber.org/mock/gomock" +) + +// MockResolver is a mock of Resolver interface. +type MockResolver struct { + ctrl *gomock.Controller + recorder *MockResolverMockRecorder + isgomock struct{} +} + +// MockResolverMockRecorder is the mock recorder for MockResolver. +type MockResolverMockRecorder struct { + mock *MockResolver +} + +// NewMockResolver creates a new mock instance. +func NewMockResolver(ctrl *gomock.Controller) *MockResolver { + mock := &MockResolver{ctrl: ctrl} + mock.recorder = &MockResolverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockResolver) EXPECT() *MockResolverMockRecorder { + return m.recorder +} + +// Resolve mocks base method. +func (m *MockResolver) Resolve(ctx context.Context, request entity.Request) ([]projectresult.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resolve", ctx, request) + ret0, _ := ret[0].([]projectresult.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockResolverMockRecorder) Resolve(ctx, request any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockResolver)(nil).Resolve), ctx, request) +} + +// MockFactory is a mock of Factory interface. +type MockFactory struct { + ctrl *gomock.Controller + recorder *MockFactoryMockRecorder + isgomock struct{} +} + +// MockFactoryMockRecorder is the mock recorder for MockFactory. +type MockFactoryMockRecorder struct { + mock *MockFactory +} + +// NewMockFactory creates a new mock instance. +func NewMockFactory(ctrl *gomock.Controller) *MockFactory { + mock := &MockFactory{ctrl: ctrl} + mock.recorder = &MockFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { + return m.recorder +} + +// For mocks base method. +func (m *MockFactory) For(cfg projectresult.Config) (projectresult.Resolver, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "For", cfg) + ret0, _ := ret[0].(projectresult.Resolver) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// For indicates an expected call of For. +func (mr *MockFactoryMockRecorder) For(cfg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockFactory)(nil).For), cfg) +} diff --git a/stovepipe/extension/projectresult/noop/BUILD.bazel b/stovepipe/extension/projectresult/noop/BUILD.bazel new file mode 100644 index 000000000..6a5e4ddc6 --- /dev/null +++ b/stovepipe/extension/projectresult/noop/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["noop.go"], + importpath = "github.com/uber/submitqueue/stovepipe/extension/projectresult/noop", + visibility = ["//visibility:public"], + deps = [ + "//stovepipe/entity:go_default_library", + "//stovepipe/extension/projectresult:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["noop_test.go"], + embed = [":go_default_library"], + deps = [ + "//stovepipe/entity:go_default_library", + "//stovepipe/extension/projectresult:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/stovepipe/extension/projectresult/noop/noop.go b/stovepipe/extension/projectresult/noop/noop.go new file mode 100644 index 000000000..137793f54 --- /dev/null +++ b/stovepipe/extension/projectresult/noop/noop.go @@ -0,0 +1,46 @@ +// 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 noop provides a projectresult.Factory that records no project +// outcomes. Use it when a deployment has not configured project attribution. +package noop + +import ( + "context" + + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/projectresult" +) + +// Verify interface compliance at compile time. +var _ projectresult.Factory = Factory{} + +// Factory returns a resolver that records no project outcomes. +type Factory struct{} + +// New returns a no-op project-result Factory. +func New() Factory { + return Factory{} +} + +// For returns the no-op resolver for a queue. +func (Factory) For(projectresult.Config) (projectresult.Resolver, error) { + return resolver{}, nil +} + +type resolver struct{} + +func (resolver) Resolve(context.Context, entity.Request) ([]projectresult.Result, error) { + return nil, nil +} diff --git a/stovepipe/extension/projectresult/noop/noop_test.go b/stovepipe/extension/projectresult/noop/noop_test.go new file mode 100644 index 000000000..893fad52a --- /dev/null +++ b/stovepipe/extension/projectresult/noop/noop_test.go @@ -0,0 +1,33 @@ +// 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 noop + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/projectresult" +) + +func TestFactory_ReturnsResolverWithoutProjectResults(t *testing.T) { + resolver, err := New().For(projectresult.Config{QueueName: "monorepo/main"}) + require.NoError(t, err) + + results, err := resolver.Resolve(context.Background(), entity.Request{}) + require.NoError(t, err) + require.Empty(t, results) +} diff --git a/stovepipe/extension/projectresult/projectresult.go b/stovepipe/extension/projectresult/projectresult.go new file mode 100644 index 000000000..99906128e --- /dev/null +++ b/stovepipe/extension/projectresult/projectresult.go @@ -0,0 +1,52 @@ +// 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 projectresult defines the optional integration that attributes a +// completed validation to named projects. +package projectresult + +//go:generate go run go.uber.org/mock/mockgen -source=projectresult.go -destination=mock/projectresult_mock.go -package=mock + +import ( + "context" + + "github.com/uber/submitqueue/stovepipe/entity" +) + +// Result is one project-scoped validation outcome. The record stage supplies +// the request identity and recording timestamp when it persists this result. +type Result struct { + // Project identifies the project to which this result applies. + Project string + // Degree describes how broken the project is, on the closed interval [0, 1]. + Degree float64 +} + +// Resolver attributes one terminal validation request to named project +// outcomes. Implementations may use any repository-specific analysis they need +// to obtain those outcomes. Returning no results is valid. +type Resolver interface { + Resolve(ctx context.Context, request entity.Request) ([]Result, error) +} + +// Config carries the queue identity handed to a Factory. +type Config struct { + // QueueName identifies the queue served by the resolver. + QueueName string +} + +// Factory constructs a Resolver for one queue. +type Factory interface { + For(cfg Config) (Resolver, error) +}