Skip to content
Closed
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
19 changes: 8 additions & 11 deletions stovepipe/controller/record/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down
49 changes: 38 additions & 11 deletions stovepipe/controller/record/record_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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
},
)
Expand Down
44 changes: 42 additions & 2 deletions stovepipe/core/requestlog/materializer.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ package requestlog

import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"time"

Expand All @@ -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"
)
Expand Down Expand Up @@ -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()
Expand Down
40 changes: 40 additions & 0 deletions stovepipe/core/requestlog/materializer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion stovepipe/entity/request_log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
},
Expand Down
Loading