diff --git a/stovepipe/controller/record/record.go b/stovepipe/controller/record/record.go index f3cb9956e..05932edcd 100644 --- a/stovepipe/controller/record/record.go +++ b/stovepipe/controller/record/record.go @@ -196,7 +196,9 @@ func (c *Controller) recordProjectFacts(ctx context.Context, store storage.Stora if err := validateProjectStatusResults(request, results); err != nil { return err } + projects := make([]string, 0, len(results)) for _, result := range results { + projects = append(projects, result.Project) _, _, err := c.recordValidationFact(ctx, store.GetValidationFactStore(), entity.ValidationFact{ URI: request.URI, Project: result.Project, @@ -208,10 +210,7 @@ func (c *Controller) recordProjectFacts(ctx context.Context, store storage.Stora return err } } - if len(results) == 0 { - return nil - } - return c.persistProjectFactsRecordedLog(ctx, store, request, len(results)) + return c.persistProjectFactsRecordedLog(ctx, store, request, projects) } func validateProjectStatusResults(request entity.Request, results []projectstatus.Result) error { @@ -235,14 +234,12 @@ func (c *Controller) persistProjectFactsRecordedLog( ctx context.Context, store storage.Storage, request entity.Request, - count int, + projects []string, ) error { - log := requestlog.NewRequestEventLog( - request, - entity.RequestEventProjectFactsRecorded, - "all", - map[string]string{requestlog.MetadataKeyProjectFactCount: strconv.Itoa(count)}, - ) + log, err := requestlog.NewProjectFactsRecordedLog(request, projects) + if err != nil { + return fmt.Errorf("failed to construct project facts log for request %s: %w", request.ID, err) + } 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) } diff --git a/stovepipe/controller/record/record_test.go b/stovepipe/controller/record/record_test.go index 1589eec6d..3ea337aea 100644 --- a/stovepipe/controller/record/record_test.go +++ b/stovepipe/controller/record/record_test.go @@ -160,11 +160,17 @@ func newControllerForTopic(t *testing.T, ctrl *gomock.Controller, topicKey consu m.store.EXPECT().GetValidationFactStore().Return(m.factStore).AnyTimes() m.materializer.EXPECT().PersistLog(gomock.Any(), m.store, gomock.Any()).DoAndReturn( func(_ context.Context, _ storage.Storage, log entity.RequestLog) error { - assert.Equal(t, entity.RequestEventValidationFactRecorded, log.Event) assert.Empty(t, log.State) assert.Zero(t, log.RequestVersion) assert.Empty(t, log.OutcomeReason) - assert.NotEmpty(t, log.Metadata[requestlog.MetadataKeyFactDegree]) + switch log.Event { + case entity.RequestEventValidationFactRecorded: + assert.NotEmpty(t, log.Metadata[requestlog.MetadataKeyFactDegree]) + case entity.RequestEventProjectFactsRecorded: + assert.NotEmpty(t, log.Metadata[requestlog.MetadataKeyProjectFactNames]) + default: + t.Errorf("unexpected request event %q", log.Event) + } return nil }, ).AnyTimes() @@ -396,12 +402,16 @@ func TestProcess_RecordsNamedProjectStatusResults(t *testing.T) { 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]) + projects, err := requestlog.ProjectFactsRecordedNames(logs[1]) + require.NoError(t, err) + assert.Equal(t, []string{"project-a", "project-b"}, projects) } func TestRecordProjectFacts_PassesEmptyTerminalBuild(t *testing.T) { ctrl := gomock.NewController(t) c, m := newController(t, ctrl) + eventMaterializer := requestlogmock.NewMockMaterializer(ctrl) + c.materializer = eventMaterializer request := requestWithState(entity.RequestStateFailed) request.TerminalBuildID = "" projectStatusResolverFactory := projectstatusmock.NewMockResolverFactory(ctrl) @@ -410,9 +420,18 @@ func TestRecordProjectFacts_PassesEmptyTerminalBuild(t *testing.T) { projectStatusResolverFactory.EXPECT().For(projectstatus.ResolverConfig{QueueName: testQueue}). Return(projectStatusResolver, nil) projectStatusResolver.EXPECT().Resolve(gomock.Any(), request, "").Return(nil, nil) + var completion entity.RequestLog + eventMaterializer.EXPECT().PersistLog(gomock.Any(), m.store, gomock.Any()).DoAndReturn( + func(_ context.Context, _ storage.Storage, log entity.RequestLog) error { + completion = log + return nil + }) err := c.recordProjectFacts(queueContext(), m.store, request) require.NoError(t, err) + projects, err := requestlog.ProjectFactsRecordedNames(completion) + require.NoError(t, err) + assert.Empty(t, projects) } func TestProcess_RejectsInvalidProjectStatusResultsBeforeWritingFacts(t *testing.T) { @@ -533,20 +552,28 @@ func TestProcess_RecordsFactEventBeforeDerivedWork(t *testing.T) { var fact entity.ValidationFact m.expectFactCreated(&fact) - eventPersisted := false + factEventPersisted := false + projectEventPersisted := false eventMaterializer.EXPECT().PersistLog(gomock.Any(), m.store, gomock.Any()).DoAndReturn( func(_ context.Context, _ storage.Storage, log entity.RequestLog) error { - eventPersisted = true - assert.Equal(t, entity.RequestEventValidationFactRecorded, log.Event) - assert.Contains(t, log.ID, "event/validation_fact_recorded/") - assert.Equal(t, fact.CreatedAt, log.TimestampMs) - assert.Equal(t, "0", log.Metadata[requestlog.MetadataKeyFactDegree]) + switch log.Event { + case entity.RequestEventValidationFactRecorded: + factEventPersisted = true + assert.Contains(t, log.ID, "event/validation_fact_recorded/") + assert.Equal(t, fact.CreatedAt, log.TimestampMs) + assert.Equal(t, "0", log.Metadata[requestlog.MetadataKeyFactDegree]) + case entity.RequestEventProjectFactsRecorded: + projectEventPersisted = true + default: + t.Errorf("unexpected request event %q", log.Event) + } return nil }, - ) + ).Times(2) m.queueStore.EXPECT().Get(gomock.Any(), testQueue).DoAndReturn( func(context.Context, string) (entity.Queue, error) { - require.True(t, eventPersisted) + require.True(t, factEventPersisted) + require.True(t, projectEventPersisted) return queueRow("", "", 1), nil }, ) diff --git a/stovepipe/core/requestlog/materializer.go b/stovepipe/core/requestlog/materializer.go index a572450f3..312eacc80 100644 --- a/stovepipe/core/requestlog/materializer.go +++ b/stovepipe/core/requestlog/materializer.go @@ -19,8 +19,10 @@ package requestlog import ( "context" + "encoding/json" "errors" "fmt" + "sort" "strconv" "time" @@ -40,8 +42,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" + // MetadataKeyProjectFactNames records the project names in a resolved project-fact set. + MetadataKeyProjectFactNames = "project_fact_names" // MetadataKeyBaseURI records the selected build baseline; an empty value means a full build. MetadataKeyBaseURI = "base_uri" ) @@ -103,6 +105,44 @@ func NewRequestEventLog( } } +// NewProjectFactsRecordedLog constructs the completion occurrence for a request's resolved project facts. +func NewProjectFactsRecordedLog(request entity.Request, projects []string) (entity.RequestLog, error) { + ordered := make([]string, len(projects)) + copy(ordered, projects) + sort.Strings(ordered) + encoded, err := json.Marshal(ordered) + if err != nil { + return entity.RequestLog{}, fmt.Errorf("encode project-facts completion occurrence: %w", err) + } + return NewRequestEventLog( + request, + entity.RequestEventProjectFactsRecorded, + "all", + map[string]string{ + MetadataKeyProjectFactNames: string(encoded), + }, + ), nil +} + +// ProjectFactsRecordedNames decodes the project names from a project-facts completion occurrence. +func ProjectFactsRecordedNames(log entity.RequestLog) ([]string, error) { + if log.Event != entity.RequestEventProjectFactsRecorded { + return nil, fmt.Errorf("request log %s is not a project-facts completion occurrence", log.ID) + } + encoded, ok := log.Metadata[MetadataKeyProjectFactNames] + if !ok { + return nil, fmt.Errorf("project-facts completion occurrence %s has no project names", log.ID) + } + var projects []string + if err := json.Unmarshal([]byte(encoded), &projects); err != nil { + return nil, fmt.Errorf("decode projects from completion occurrence %s: %w", log.ID, err) + } + if projects == nil { + projects = []string{} + } + return projects, nil +} + func (m *materializer) PersistLog(ctx context.Context, stores storage.Storage, log entity.RequestLog) error { if log.TimestampMs == 0 { log.TimestampMs = m.now().UnixMilli() diff --git a/stovepipe/core/requestlog/materializer_test.go b/stovepipe/core/requestlog/materializer_test.go index 6f3ed0921..c9f8b5594 100644 --- a/stovepipe/core/requestlog/materializer_test.go +++ b/stovepipe/core/requestlog/materializer_test.go @@ -334,6 +334,46 @@ func TestNewRequestEventLogStableID(t *testing.T) { assert.Equal(t, metadata, first.Metadata) } +func TestProjectFactsRecordedLog(t *testing.T) { + request := entity.Request{ID: testRequestID, Queue: testQueue} + log, err := NewProjectFactsRecordedLog(request, []string{"project-b", "project-a"}) + require.NoError(t, err) + + assert.Equal(t, "event/project_facts_recorded/all", log.ID) + assert.Equal(t, entity.RequestEventProjectFactsRecorded, log.Event) + projects, err := ProjectFactsRecordedNames(log) + require.NoError(t, err) + assert.Equal(t, []string{"project-a", "project-b"}, projects) + + empty, err := NewProjectFactsRecordedLog(request, nil) + require.NoError(t, err) + projects, err = ProjectFactsRecordedNames(empty) + require.NoError(t, err) + assert.Empty(t, projects) +} + +func TestProjectFactsRecordedNamesRejectsInvalidLog(t *testing.T) { + tests := []struct { + name string + log entity.RequestLog + }{ + {name: "wrong event", log: entity.RequestLog{ID: "event/build", Event: entity.RequestEventBuildFinished}}, + {name: "missing projects", log: entity.RequestLog{ID: "event/projects", Event: entity.RequestEventProjectFactsRecorded}}, + {name: "malformed projects", log: entity.RequestLog{ + ID: "event/projects", + Event: entity.RequestEventProjectFactsRecorded, + Metadata: map[string]string{MetadataKeyProjectFactNames: "{"}, + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ProjectFactsRecordedNames(tt.log) + require.Error(t, err) + }) + } +} + func TestSameSemanticOccurrenceMetadata(t *testing.T) { base := entity.RequestLog{ID: "log/1", Queue: testQueue, RequestID: testRequestID, State: entity.RequestStateAccepted, RequestVersion: 1} stored := base diff --git a/stovepipe/entity/request_log_test.go b/stovepipe/entity/request_log_test.go index 67954c209..24932f449 100644 --- a/stovepipe/entity/request_log_test.go +++ b/stovepipe/entity/request_log_test.go @@ -112,7 +112,7 @@ func TestRequestLogValidate(t *testing.T) { entry.State = RequestStateUnknown entry.Event = RequestEventProjectFactsRecorded entry.RequestVersion = 0 - entry.Metadata = map[string]string{"project_fact_count": "2"} + entry.Metadata = map[string]string{"project_fact_names": `["project-a","project-b"]`} return entry }, },