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: 1 addition & 1 deletion stovepipe/controller/fix/fix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ func TestProcessBuildChecksLaterPagesForRecordedFix(t *testing.T) {
Return([]entity.BuildIssue{issueIdentity(issue)}, nil),
m.issues.EXPECT().Get(gomock.Any(), issue.TaskID, issue.UnitID).Return(storedIssue(issue), nil),
m.observations.EXPECT().List(gomock.Any(), testBuildID, afterIssue(issueIdentity(issue))).
Return([]entity.BuildIssue{{TaskID: "resolved", FixID: "fix/1"}}, nil),
Return([]entity.BuildIssue{{TaskID: "closed", FixID: "fix/1"}}, nil),
)
require.NoError(t, controller.processBuild(context.Background(), testQueue, testBuildID))
}
Expand Down
14 changes: 7 additions & 7 deletions stovepipe/controller/issuetrack/delivery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ func TestFinalizationOpensKeepsAndClosesIssues(t *testing.T) {
require.Equal(t, "build", f.states[issueKey{"c", "unit"}].FirstSeenBuildID)
require.Equal(t, int32(8), f.states[issueKey{"closed", ""}].Version)
require.Len(t, f.logs, 2)
require.Contains(t, f.logs, logKey{issueKey{"a", ""}, "build", entity.IssueEventResolved})
require.Contains(t, f.logs, logKey{issueKey{"c", "unit"}, "build", entity.IssueEventOpened})
require.Contains(t, f.logs, logKey{issueKey{"a", ""}, "build", entity.IssueStatusClosed})
require.Contains(t, f.logs, logKey{issueKey{"c", "unit"}, "build", entity.IssueStatusOpen})
}

func TestEmptyFinalBuildClosesIssuesButRetainsTheirRows(t *testing.T) {
Expand Down Expand Up @@ -259,7 +259,7 @@ func TestRetryRepairsHistoryBeforeFinalizing(t *testing.T) {
require.Equal(t, "task", f.progress.CursorTaskID)
require.Equal(t, "unit", f.progress.CursorUnitID)
require.Equal(t, int32(1), f.states[issueKey{"task", "unit"}].Version)
require.Equal(t, int64(1000), f.logs[logKey{issueKey{"task", "unit"}, "build", entity.IssueEventOpened}].TimestampMs)
require.Equal(t, int64(1000), f.logs[logKey{issueKey{"task", "unit"}, "build", entity.IssueStatusOpen}].TimestampMs)
}

func TestRetryFinishesTheCursorIssueBeforeScanningTheNextBatch(t *testing.T) {
Expand Down Expand Up @@ -411,7 +411,7 @@ func TestHistoryRecordsTheActualChangingBuildNotTheLatestProcessedBuild(t *testi
require.Empty(t, f.logs)
f.controller.nowMs = func() int64 { return 2000 }
require.NoError(t, f.deliver(msg, false))
require.Equal(t, int64(1000), f.logs[logKey{issueKey{"task", "unit"}, "build/100", entity.IssueEventResolved}].TimestampMs)
require.Equal(t, int64(1000), f.logs[logKey{issueKey{"task", "unit"}, "build/100", entity.IssueStatusClosed}].TimestampMs)
require.Equal(t, changed, f.states[issueKey{"task", "unit"}])

f.source("build/101", "101", entity.BuildStatusSucceeded)
Expand Down Expand Up @@ -449,7 +449,7 @@ func TestTaskWideFailurePreservesAllExistingIssues(t *testing.T) {
require.Equal(t, entity.IssueStatusClosed, f.states[issueKey{"old", "closed"}].Status)
require.Len(t, f.logs, 3)
for key := range f.logs {
require.Equal(t, entity.IssueEventOpened, key.event)
require.Equal(t, entity.IssueStatusOpen, key.status)
}
require.Equal(t, 1, f.failures["issue/list"])

Expand Down Expand Up @@ -489,7 +489,7 @@ func TestTaskWideFailureInLaterOpeningBatchPreventsAllClosures(t *testing.T) {
require.Equal(t, entity.IssueStatusOpen, f.states[issueKey{"old", "unit"}].Status)
require.Len(t, f.logs, 106)
for key := range f.logs {
require.Equal(t, entity.IssueEventOpened, key.event)
require.Equal(t, entity.IssueStatusOpen, key.status)
}
}

Expand Down Expand Up @@ -591,7 +591,7 @@ func TestTaskWideHistoryRetryFinishesWithoutClosing(t *testing.T) {
require.NoError(t, f.deliver(msg, false))
require.Equal(t, entity.IssueTrackingPhaseFinalized, f.progress.Phase)
require.Equal(t, entity.IssueStatusOpen, f.states[issueKey{"old", "unit"}].Status)
require.Equal(t, int64(1000), f.logs[logKey{issueKey{"task", ""}, "build", entity.IssueEventOpened}].TimestampMs)
require.Equal(t, int64(1000), f.logs[logKey{issueKey{"task", ""}, "build", entity.IssueStatusOpen}].TimestampMs)
require.Equal(t, 1, f.failures["issue/list"])
}

Expand Down
12 changes: 4 additions & 8 deletions stovepipe/controller/issuetrack/dlq_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,7 @@ func TestRecoveryKeepsAcceptedIssueChanges(t *testing.T) {
for key, state := range f.states {
untouched[key] = state
}
event := entity.IssueEventOpened
if status == entity.IssueStatusClosed {
event = entity.IssueEventResolved
}
delete(f.initialLogs, logKey{issueKey{"task", "unit"}, original.LastChangedBuildID, event})
delete(f.initialLogs, logKey{issueKey{"task", "unit"}, original.LastChangedBuildID, status})
f.failures["issue/list"], f.failures["build-issues/list"], f.failures["build-issues/get"] = 1, 1, 1

require.NoError(t, f.recover(msg, false))
Expand All @@ -106,7 +102,7 @@ func TestRecoveryKeepsAcceptedIssueChanges(t *testing.T) {
}
require.Equal(t, expected, f.states[key])
}
require.Contains(t, f.logs, logKey{issueKey{"task", "unit"}, original.LastChangedBuildID, event})
require.Contains(t, f.logs, logKey{issueKey{"task", "unit"}, original.LastChangedBuildID, status})
require.Len(t, f.events, 1)
require.Equal(t, 1, f.failures["issue/list"])
require.Equal(t, 1, f.failures["build-issues/list"])
Expand Down Expand Up @@ -193,7 +189,7 @@ func TestRecoveryDoesNotReopenAnExistingClosedCursor(t *testing.T) {
require.NoError(t, f.recover(msg, false))
require.Equal(t, entity.IssueStatusClosed, f.states[issueKey{"task", "unit"}].Status)
require.Equal(t, "older", f.states[issueKey{"task", "unit"}].FirstSeenBuildID)
require.NotContains(t, f.logs, logKey{issueKey{"task", "unit"}, "build/100", entity.IssueEventOpened})
require.NotContains(t, f.logs, logKey{issueKey{"task", "unit"}, "build/100", entity.IssueStatusOpen})
}

func TestRecoveryRetriesOnlyRecoveryWork(t *testing.T) {
Expand Down Expand Up @@ -239,7 +235,7 @@ func TestRecoveryRetainsAnOpeningAcrossRetries(t *testing.T) {

require.NoError(t, f.recover(msg, false))
issue := f.states[issueKey{"task", "unit"}]
log := f.logs[logKey{issueKey{"task", "unit"}, "build/100", entity.IssueEventOpened}]
log := f.logs[logKey{issueKey{"task", "unit"}, "build/100", entity.IssueStatusOpen}]
require.Equal(t, "build/100", issue.FirstSeenBuildID)
require.Equal(t, issue.ChangedAtMs, log.TimestampMs)
if step == "issue/create" {
Expand Down
2 changes: 1 addition & 1 deletion stovepipe/controller/issuetrack/fix_handoff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func TestOpeningPassPublishesOneBuildRepairRequest(t *testing.T) {
require.Equal(t, entity.IssueTrackingPhaseRefreshing, f.progress.Phase)
for _, task := range []string{"new", "reopened"} {
require.Equal(t, entity.IssueStatusOpen, f.states[issueKey{task, "unit"}].Status)
require.Contains(t, f.logs, logKey{issueKey{task, "unit"}, "build", entity.IssueEventOpened})
require.Contains(t, f.logs, logKey{issueKey{task, "unit"}, "build", entity.IssueStatusOpen})
}
payload := &stovepipemq.FixRequest{}
require.NoError(t, stovepipemq.Unmarshal(msg.Payload, payload))
Expand Down
12 changes: 4 additions & 8 deletions stovepipe/controller/issuetrack/fixture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ const testQueue = "validation/main"

type logKey struct {
issueKey
build string
event entity.IssueEvent
build string
status entity.IssueStatus
}

type fixture struct {
Expand Down Expand Up @@ -119,7 +119,7 @@ func newFixture(t *testing.T) *fixture {
if err := f.fail("history/create"); err != nil {
return err
}
key := logKey{issueKey{log.TaskID, log.UnitID}, log.BuildID, log.Event}
key := logKey{issueKey{log.TaskID, log.UnitID}, log.BuildID, log.Status}
if _, ok := f.initialLogs[key]; ok {
return storage.ErrAlreadyExists
}
Expand Down Expand Up @@ -304,9 +304,5 @@ func (f *fixture) reportIssue(build, task, unit string) {

func (f *fixture) seedIssue(task, unit string, status entity.IssueStatus) {
f.states[issueKey{task, unit}] = entity.IssueState{TaskID: task, UnitID: unit, Status: status, FirstSeenBuildID: "older", Version: 7, LastProcessedBuildID: "older", LastChangedBuildID: "older", ChangedAtMs: 500}
event := entity.IssueEventOpened
if status == entity.IssueStatusClosed {
event = entity.IssueEventResolved
}
f.initialLogs[logKey{issueKey{task, unit}, "older", event}] = entity.IssueLog{TaskID: task, UnitID: unit, BuildID: "older", Event: event, TimestampMs: 500}
f.initialLogs[logKey{issueKey{task, unit}, "older", status}] = entity.IssueLog{TaskID: task, UnitID: unit, BuildID: "older", Status: status, TimestampMs: 500}
}
7 changes: 1 addition & 6 deletions stovepipe/controller/issuetrack/issuetrack.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,12 +388,7 @@ func (c *Controller) processIssue(ctx context.Context, store storage.Storage, pr
}

func (c *Controller) recordHistory(ctx context.Context, store storage.Storage, issue entity.IssueState) error {
event := entity.IssueEventOpened
if issue.Status == entity.IssueStatusClosed {
event = entity.IssueEventResolved
}

log := entity.IssueLog{TaskID: issue.TaskID, UnitID: issue.UnitID, BuildID: issue.LastChangedBuildID, Event: event, TimestampMs: issue.ChangedAtMs}
log := entity.IssueLog{TaskID: issue.TaskID, UnitID: issue.UnitID, BuildID: issue.LastChangedBuildID, Status: issue.Status, TimestampMs: issue.ChangedAtMs}
if err := store.GetIssueLogStore().Create(ctx, log); err != nil && !errors.Is(err, storage.ErrAlreadyExists) {
return fmt.Errorf("record issue transition: %w", err)
}
Expand Down
6 changes: 3 additions & 3 deletions stovepipe/controller/issuetrack/issuetrack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,12 @@ func TestProcessIssueOpensAndCloses(t *testing.T) {
require.Equal(t, entity.IssueStatusOpen, issue.Status)
require.Equal(t, "build/100", issue.FirstSeenBuildID)
require.Equal(t, int32(1), issue.Version)
require.Contains(t, f.logs, logKey{issueKey{"task", "unit"}, "build/100", entity.IssueEventOpened})
require.Contains(t, f.logs, logKey{issueKey{"task", "unit"}, "build/100", entity.IssueStatusOpen})
} else {
require.Equal(t, entity.IssueStatusClosed, issue.Status)
require.Equal(t, "older", issue.FirstSeenBuildID)
require.Equal(t, int32(8), issue.Version)
require.Contains(t, f.logs, logKey{issueKey{"task", "unit"}, "build/100", entity.IssueEventResolved})
require.Contains(t, f.logs, logKey{issueKey{"task", "unit"}, "build/100", entity.IssueStatusClosed})
}
})
}
Expand Down Expand Up @@ -138,7 +138,7 @@ func TestHistoryRetryUsesTheStoredTransition(t *testing.T) {
_, err = f.controller.processIssue(context.Background(), f.store, f.progress, issueKey{"task", "unit"}, false)
require.NoError(t, err)
require.Equal(t, applied, f.states[issueKey{"task", "unit"}])
require.Equal(t, int64(1000), f.logs[logKey{issueKey{"task", "unit"}, "build/100", entity.IssueEventOpened}].TimestampMs)
require.Equal(t, int64(1000), f.logs[logKey{issueKey{"task", "unit"}, "build/100", entity.IssueStatusOpen}].TimestampMs)
}

func TestCursorFailureDoesNotMutateTheIssue(t *testing.T) {
Expand Down
4 changes: 2 additions & 2 deletions stovepipe/core/messagequeue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ Payloads are defined in proto3 (`proto/`, generated into `protopb/`) and seriali

`IssueTrack` binds to `TopicKeyIssueTrack` and identifies a persisted build and its queue. Notifications are partitioned by queue so both running and final issue-state updates share one serialized path, independently of build-partitioned analysis polling. The message carries identifiers rather than a failure collection; per-build observations are read through `BuildIssueStore.List`.

Publish only after detection and observation persistence succeed. With `finalize` unset, the notification permits opening newly detected issues, not resolution by absence. With `finalize` set, it requests final reconciliation after a successful terminal analysis. Publish that notification even when no failures were detected; terminal Build status alone does not replace the handoff. Publication retries must also follow duplicate observation writes so a crash between persistence and publication does not strand work.
Publish only after detection and observation persistence succeed. With `finalize` unset, the notification permits opening newly detected issues, not closing by absence. With `finalize` set, it requests final reconciliation after a successful terminal analysis. Publish that notification even when no failures were detected; terminal Build status alone does not replace the handoff. Publication retries must also follow duplicate observation writes so a crash between persistence and publication does not strand work.

Per-build observations accumulate historical evidence; finalization does not turn them into a replacement snapshot or override the detector's absence semantics. The reconciler must establish the authoritative final failure set and applicable validation scope before resolving issues, reject older requests, and ignore running notifications after finalization. Notification identity must distinguish new observations from finalization while remaining stable on publication retries. Business behavior follows the payload, not a parsed message ID.
Per-build observations accumulate historical evidence; finalization does not turn them into a replacement snapshot or override the detector's absence semantics. The reconciler must establish the authoritative final failure set and applicable validation scope before closing issues, reject older requests, and ignore running notifications after finalization. Notification identity must distinguish new observations from finalization while remaining stable on publication retries. Business behavior follows the payload, not a parsed message ID.

Only the handoff contract is defined. No publisher, consumer, topic registration, reconciliation progress, or writes to open issues and lifecycle history are enabled.
26 changes: 7 additions & 19 deletions stovepipe/entity/issue_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,16 @@ package entity

import "fmt"

// IssueEvent identifies an issue lifecycle transition.
type IssueEvent string

const (
// IssueEventUnknown is the unset event value.
IssueEventUnknown IssueEvent = ""
// IssueEventOpened records that an issue became open.
IssueEventOpened IssueEvent = "opened"
// IssueEventResolved records that an issue was resolved.
IssueEventResolved IssueEvent = "resolved"
)

// IssueLog is an immutable issue lifecycle transition within a queue.
// Task, unit, build, and event identify one transition.
// Task, unit, build, and status identify one transition.
type IssueLog struct {
// TaskID identifies the logical validation task.
TaskID string `json:"task_id"`
// UnitID identifies the validation unit; empty for a task-wide issue.
UnitID string `json:"unit_id"`
// Event identifies the opening or resolution.
Event IssueEvent `json:"event"`
// BuildID identifies the build where the issue was first seen or confirmed resolved.
// Status is the open or closed status established by this transition.
Status IssueStatus `json:"status"`
// BuildID identifies the build where the issue was first seen or confirmed closed.
BuildID string `json:"build_id"`
// TimestampMs is when the transition was first recorded, in Unix milliseconds.
TimestampMs int64 `json:"timestamp_ms"`
Expand All @@ -51,10 +39,10 @@ func (l IssueLog) Validate() error {
if l.TimestampMs <= 0 {
return fmt.Errorf("issue log timestamp must be positive")
}
switch l.Event {
case IssueEventOpened, IssueEventResolved:
switch l.Status {
case IssueStatusOpen, IssueStatusClosed:
return nil
default:
return fmt.Errorf("invalid issue event %q", l.Event)
return fmt.Errorf("invalid issue status %q", l.Status)
}
}
10 changes: 5 additions & 5 deletions stovepipe/entity/issue_log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,18 @@ func TestIssueLogValidate(t *testing.T) {
change func(*IssueLog)
invalid bool
}{
{name: "opened"},
{name: "resolved", change: func(l *IssueLog) { l.Event = IssueEventResolved }},
{name: "open"},
{name: "closed", change: func(l *IssueLog) { l.Status = "closed" }},
{name: "task-wide issue", change: func(l *IssueLog) { l.UnitID = "" }},
{name: "missing build", change: func(l *IssueLog) { l.BuildID = "" }, invalid: true},
{name: "zero timestamp", change: func(l *IssueLog) { l.TimestampMs = 0 }, invalid: true},
{name: "negative timestamp", change: func(l *IssueLog) { l.TimestampMs = -1 }, invalid: true},
{name: "unknown event", change: func(l *IssueLog) { l.Event = IssueEventUnknown }, invalid: true},
{name: "unsupported event", change: func(l *IssueLog) { l.Event = "other" }, invalid: true},
{name: "unknown status", change: func(l *IssueLog) { l.Status = IssueStatusUnknown }, invalid: true},
{name: "unsupported status", change: func(l *IssueLog) { l.Status = "other" }, invalid: true},
} {
t.Run(tt.name, func(t *testing.T) {
log := IssueLog{TaskID: "task", UnitID: "unit",
Event: IssueEventOpened, BuildID: "build/1", TimestampMs: 1000}
Status: IssueStatusOpen, BuildID: "build/1", TimestampMs: 1000}
if tt.change != nil {
tt.change(&log)
}
Expand Down
Loading
Loading