diff --git a/stovepipe/controller/issuetrack/BUILD.bazel b/stovepipe/controller/issuetrack/BUILD.bazel new file mode 100644 index 000000000..4e1c9ec6f --- /dev/null +++ b/stovepipe/controller/issuetrack/BUILD.bazel @@ -0,0 +1,35 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["issuetrack.go"], + importpath = "github.com/uber/submitqueue/stovepipe/controller/issuetrack", + visibility = ["//visibility:public"], + deps = [ + "//platform/base/id:go_default_library", + "//platform/consumer:go_default_library", + "//platform/metrics:go_default_library", + "//stovepipe/core/loader:go_default_library", + "//stovepipe/entity:go_default_library", + "//stovepipe/extension/storage:go_default_library", + "@com_github_uber_go_tally//:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["issuetrack_test.go"], + embed = [":go_default_library"], + deps = [ + "//stovepipe/core/messagequeue:go_default_library", + "//stovepipe/entity:go_default_library", + "//stovepipe/extension/storage:go_default_library", + "//stovepipe/extension/storage/mock:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + "@com_github_uber_go_tally//:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + "@org_uber_go_zap//:go_default_library", + ], +) diff --git a/stovepipe/controller/issuetrack/README.md b/stovepipe/controller/issuetrack/README.md new file mode 100644 index 000000000..8461fb849 --- /dev/null +++ b/stovepipe/controller/issuetrack/README.md @@ -0,0 +1,19 @@ +# Issue reconciliation + +Each batch processes at most 100 distinct task/unit pairs and saves its progress. + +- A refresh reads the build's issues and opens any that are not already open. It does not close issues. +- Finalization also checks the queue's open issues. It opens or keeps issues reported by the build and closes existing issues that the build did not report. +- A canceled build can open issues it reported, but cannot close existing issues just because it did not report them: cancellation may have prevented that validation from running. + +The controller combines the next bounded read of build issues and open issues, orders them by task/unit, and processes the first 100 distinct pairs. Both reads start after the same saved task/unit pair, so no separate opening and closing scans are needed. A full read may require another batch to confirm there are no more issues. + +After all issue and history writes succeed, the controller saves the last fully processed pair and increments the progress version. Finished passes become refreshed or finalized. If a write fails, the saved cursor stays unchanged and the next attempt repeats that batch safely. Closing records history before deleting the open issue, so a failed history write leaves the issue available for retry. Each batch reloads its build and request; finalization requires the request's winning terminal build. + +Build issues added behind a refresh's cursor are picked up by a later refresh, which starts from the beginning. Before requesting finalization, the producer must successfully detect and persist the build's complete issue set. Finalization uses all issues accumulated for that build, not just the latest detector response. + +This layer does not receive messages, publish repair requests, register consumers, or implement recovery. Delivery orchestration is added separately. A future repair handoff must persist what the repair needs and publish before saving a cursor that would skip that work on retry. + +## Ownership prerequisite + +Do not enable this controller until an old worker is prevented from writing after a new worker takes over its queue. Otherwise, the old worker could reopen an issue a newer build already closed. Rejecting its progress update afterward does not undo those issue writes. Processing one message at a time within each worker does not prevent this overlap between workers. diff --git a/stovepipe/controller/issuetrack/issuetrack.go b/stovepipe/controller/issuetrack/issuetrack.go new file mode 100644 index 000000000..edd994433 --- /dev/null +++ b/stovepipe/controller/issuetrack/issuetrack.go @@ -0,0 +1,253 @@ +// 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 issuetrack applies build issues to operational open issues and lifecycle history. +// All deliveries for one queue must share its partition and consumer group. +package issuetrack + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + "github.com/uber-go/tally" + "github.com/uber/submitqueue/platform/base/id" + "github.com/uber/submitqueue/platform/consumer" + "github.com/uber/submitqueue/platform/metrics" + "github.com/uber/submitqueue/stovepipe/core/loader" + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/storage" + "go.uber.org/zap" +) + +// Each batch applies at most this many distinct issue keys. +const _issueBatchSize = 100 + +type issueKey struct{ task, unit string } + +// Controller is the queue-serialized writer of open issues and issue transitions. +// Accepted pending work is replayed before a newer source can change issue state. +type Controller struct { + logger *zap.SugaredLogger + metricsScope tally.Scope + stores storage.Factory + topicKey consumer.TopicKey + consumerGroup string + nowMs func() int64 +} + +func NewController( + logger *zap.SugaredLogger, + scope tally.Scope, + stores storage.Factory, + topicKey consumer.TopicKey, + consumerGroup string, +) *Controller { + return &Controller{ + logger: logger.Named("issuetrack_controller"), + metricsScope: scope.SubScope("issuetrack_controller"), + stores: stores, + topicKey: topicKey, + consumerGroup: consumerGroup, + nowMs: func() int64 { return time.Now().UnixMilli() }, + } +} + +func (c *Controller) reconcileBatch(ctx context.Context, store storage.Storage, queue string, progress entity.IssueTrackingState) (checkpoint entity.IssueTrackingState, retErr error) { + op := metrics.Begin(c.metricsScope, "batch", metrics.LongLatencyBuckets, metrics.TagsFromContext(ctx)...) + defer func() { op.Complete(retErr) }() + + build, request, err := loadBuildAndRequest(ctx, store, queue, progress.BuildID) + if err != nil { + return entity.IssueTrackingState{}, err + } + if request.ID != progress.RequestID || !progress.IsPending() { + return entity.IssueTrackingState{}, fmt.Errorf("issue batch requires pending work for its source request") + } + finalizing := progress.Phase == entity.IssueTrackingPhaseFinalizing + if finalizing && (!build.Status.IsTerminal() || request.TerminalBuildID != build.ID) { + return entity.IssueTrackingState{}, fmt.Errorf("final issue reconciliation requires the request's terminal build") + } + + buildIssues, err := store.GetBuildIssueStore().List(ctx, build.ID, storage.BuildIssueRange{ + After: storage.BuildIssueCursor{TaskID: progress.AfterTaskID, UnitID: progress.AfterUnitID}, + Limit: _issueBatchSize, + }) + if err != nil { + return entity.IssueTrackingState{}, fmt.Errorf("list build issues: %w", err) + } + + var openIssues []entity.OpenIssue + // A canceled build may not have run the validation that would find an existing issue. + // Keep those issues open even when the canceled build does not report them. + if finalizing && build.Status != entity.BuildStatusCancelled { + openIssues, err = store.GetOpenIssueStore().List(ctx, storage.OpenIssueRange{ + After: storage.OpenIssueCursor{TaskID: progress.AfterTaskID, UnitID: progress.AfterUnitID}, + Limit: _issueBatchSize, + }) + if err != nil { + return entity.IssueTrackingState{}, fmt.Errorf("list open issues: %w", err) + } + } + + // Combine both bounded reads by task/unit identity, then process the first batch in key order. + buildByKey := make(map[issueKey]entity.BuildIssue, len(buildIssues)) + openByKey := make(map[issueKey]entity.OpenIssue, len(openIssues)) + keys := make([]issueKey, 0, len(buildIssues)+len(openIssues)) + for _, issue := range buildIssues { + key := issueKey{issue.TaskID, issue.UnitID} + buildByKey[key] = issue + keys = append(keys, key) + } + for _, issue := range openIssues { + key := issueKey{issue.TaskID, issue.UnitID} + openByKey[key] = issue + if _, reported := buildByKey[key]; !reported { + keys = append(keys, key) + } + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].task != keys[j].task { + return keys[i].task < keys[j].task + } + return keys[i].unit < keys[j].unit + }) + + // A full read may hide more keys. An oversized combined set also needs another batch. + complete := len(buildIssues) < _issueBatchSize && len(openIssues) < _issueBatchSize && len(keys) <= _issueBatchSize + if len(keys) > _issueBatchSize { + keys = keys[:_issueBatchSize] + } + + next := progress + for _, key := range keys { + if err := ctx.Err(); err != nil { + return entity.IssueTrackingState{}, err + } + + if issue, reported := buildByKey[key]; reported { + if err := c.openBuildIssue(ctx, store, build.ID, issue); err != nil { + return entity.IssueTrackingState{}, err + } + } else { + if err := c.closeIssueIfAbsent(ctx, store, build.ID, openByKey[key]); err != nil { + return entity.IssueTrackingState{}, err + } + } + + next.AfterTaskID, next.AfterUnitID = key.task, key.unit + } + + if complete { + next.Phase = entity.IssueTrackingPhaseRefreshed + if finalizing { + next.Phase = entity.IssueTrackingPhaseFinalized + } + } + + newVersion := progress.Version + 1 + if err := store.GetIssueTrackingStore().Update(ctx, next, progress.Version, newVersion); err != nil { + return entity.IssueTrackingState{}, fmt.Errorf("checkpoint issue reconciliation: %w", err) + } + next.Version = newVersion + return next, nil +} + +func (c *Controller) openBuildIssue(ctx context.Context, store storage.Storage, buildID string, issue entity.BuildIssue) error { + openStore := store.GetOpenIssueStore() + openIssue, err := openStore.Get(ctx, issue.TaskID, issue.UnitID) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("read open issue: %w", err) + } + + if errors.Is(err, storage.ErrNotFound) { + openIssue = entity.OpenIssue{ + TaskID: issue.TaskID, + UnitID: issue.UnitID, + FirstSeenBuildID: buildID, + } + err = openStore.Create(ctx, openIssue) + if errors.Is(err, storage.ErrAlreadyExists) { + openIssue, err = openStore.Get(ctx, issue.TaskID, issue.UnitID) + } + if err != nil { + return fmt.Errorf("open build issue: %w", err) + } + } + + if openIssue.FirstSeenBuildID == buildID { + return c.recordIssueEvent(ctx, store, buildID, entity.IssueEventOpened, issue.TaskID, issue.UnitID) + } + return nil +} + +func (c *Controller) closeIssueIfAbsent(ctx context.Context, store storage.Storage, buildID string, issue entity.OpenIssue) error { + _, err := store.GetBuildIssueStore().Get(ctx, buildID, issue.TaskID, issue.UnitID) + if err == nil { + return nil + } + if !errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("check build issue: %w", err) + } + + // Record closure before deletion so a failed history write leaves the issue retryable. + if err := c.recordIssueEvent(ctx, store, buildID, entity.IssueEventResolved, issue.TaskID, issue.UnitID); err != nil { + return err + } + if err := store.GetOpenIssueStore().Delete(ctx, issue); err != nil { + return fmt.Errorf("close open issue: %w", err) + } + return nil +} + +func (c *Controller) recordIssueEvent(ctx context.Context, store storage.Storage, buildID string, event entity.IssueEvent, taskID, unitID string) error { + log := entity.IssueLog{ + TaskID: taskID, + UnitID: unitID, + BuildID: buildID, + Event: event, + TimestampMs: c.nowMs(), + } + if err := store.GetIssueLogStore().Create(ctx, log); err != nil && !errors.Is(err, storage.ErrAlreadyExists) { + return fmt.Errorf("record issue transition: %w", err) + } + return nil +} + +func loadBuildAndRequest(ctx context.Context, store storage.Storage, queue, buildID string) (entity.Build, entity.Request, error) { + build, err := loader.ByID(ctx, buildID, store.GetBuildStore().Get, "build") + if err != nil { + return entity.Build{}, entity.Request{}, err + } + + request, err := loader.ByID(ctx, build.RequestID, store.GetRequestStore().Get, "request") + if err != nil { + return entity.Build{}, entity.Request{}, err + } + + if request.Queue != queue { + return entity.Build{}, entity.Request{}, fmt.Errorf("issue tracking request belongs to another queue") + } + if err := id.Validate(request.ID); err != nil { + return entity.Build{}, entity.Request{}, fmt.Errorf("invalid issue tracking request ID: %w", err) + } + + return build, request, nil +} + +func (c *Controller) Name() string { return "issuetrack" } +func (c *Controller) TopicKey() consumer.TopicKey { return c.topicKey } +func (c *Controller) ConsumerGroup() string { return c.consumerGroup } diff --git a/stovepipe/controller/issuetrack/issuetrack_test.go b/stovepipe/controller/issuetrack/issuetrack_test.go new file mode 100644 index 000000000..4d50e780a --- /dev/null +++ b/stovepipe/controller/issuetrack/issuetrack_test.go @@ -0,0 +1,526 @@ +// 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 issuetrack + +import ( + "context" + "fmt" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber-go/tally" + stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue" + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/storage" + storagemock "github.com/uber/submitqueue/stovepipe/extension/storage/mock" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +const testQueue = "validation/main" + +type logKey struct { + issueKey + build string + event entity.IssueEvent +} + +type fixture struct { + t *testing.T + ctrl *gomock.Controller + controller *Controller + builds map[string]entity.Build + requests map[string]entity.Request + buildIssues map[string]map[issueKey]entity.BuildIssue + opens map[issueKey]entity.OpenIssue + logs map[logKey]entity.IssueLog + progress entity.IssueTrackingState + hasProgress bool + store storage.Storage + failures map[string]int + createRace bool + updateRace bool +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + + f := &fixture{ + t: t, + ctrl: gomock.NewController(t), + builds: map[string]entity.Build{}, + requests: map[string]entity.Request{}, + buildIssues: map[string]map[issueKey]entity.BuildIssue{}, + opens: map[issueKey]entity.OpenIssue{}, + logs: map[logKey]entity.IssueLog{}, + failures: map[string]int{}, + } + + stores := storagemock.NewMockFactory(f.ctrl) + store := storagemock.NewMockStorage(f.ctrl) + f.store = store + builds := storagemock.NewMockBuildStore(f.ctrl) + requests := storagemock.NewMockRequestStore(f.ctrl) + buildIssues := storagemock.NewMockBuildIssueStore(f.ctrl) + opens := storagemock.NewMockOpenIssueStore(f.ctrl) + logs := storagemock.NewMockIssueLogStore(f.ctrl) + progress := storagemock.NewMockIssueTrackingStore(f.ctrl) + + stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(store, nil).AnyTimes() + store.EXPECT().GetBuildStore().Return(builds).AnyTimes() + store.EXPECT().GetRequestStore().Return(requests).AnyTimes() + store.EXPECT().GetBuildIssueStore().Return(buildIssues).AnyTimes() + store.EXPECT().GetOpenIssueStore().Return(opens).AnyTimes() + store.EXPECT().GetIssueLogStore().Return(logs).AnyTimes() + store.EXPECT().GetIssueTrackingStore().Return(progress).AnyTimes() + + builds.EXPECT().Get(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, key string) (entity.Build, error) { + value, ok := f.builds[key] + if !ok { + return entity.Build{}, storage.ErrNotFound + } + return value, nil + }).AnyTimes() + + requests.EXPECT().Get(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, key string) (entity.Request, error) { + value, ok := f.requests[key] + if !ok { + return entity.Request{}, storage.ErrNotFound + } + return value, nil + }).AnyTimes() + + buildIssues.EXPECT().List(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, buildID string, bounds storage.BuildIssueRange) ([]entity.BuildIssue, error) { + if err := f.fail("buildIssues/list"); err != nil { + return nil, err + } + keys := make([]issueKey, 0) + for key := range f.buildIssues[buildID] { + if bounds.After.TaskID == "" || key.task > bounds.After.TaskID || (key.task == bounds.After.TaskID && key.unit > bounds.After.UnitID) { + keys = append(keys, key) + } + } + sortKeys(keys) + if len(keys) > bounds.Limit { + keys = keys[:bounds.Limit] + } + result := make([]entity.BuildIssue, 0, len(keys)) + for _, key := range keys { + result = append(result, f.buildIssues[buildID][key]) + } + return result, nil + }).AnyTimes() + + buildIssues.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, buildID, taskID, unitID string) (entity.BuildIssue, error) { + if err := f.fail("buildIssues/get"); err != nil { + return entity.BuildIssue{}, err + } + value, ok := f.buildIssues[buildID][issueKey{taskID, unitID}] + if !ok { + return entity.BuildIssue{}, storage.ErrNotFound + } + return value, nil + }).AnyTimes() + + opens.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, taskID, unitID string) (entity.OpenIssue, error) { + value, ok := f.opens[issueKey{taskID, unitID}] + if !ok { + return entity.OpenIssue{}, storage.ErrNotFound + } + return value, nil + }).AnyTimes() + + opens.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, issue entity.OpenIssue) error { + if err := f.fail("opens/create"); err != nil { + return err + } + key := issueKey{issue.TaskID, issue.UnitID} + if _, ok := f.opens[key]; ok { + return storage.ErrAlreadyExists + } + f.opens[key] = issue + return nil + }).AnyTimes() + + opens.EXPECT().Delete(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, issue entity.OpenIssue) error { + if err := f.fail("opens/delete"); err != nil { + return err + } + key := issueKey{issue.TaskID, issue.UnitID} + if f.opens[key].FirstSeenBuildID == issue.FirstSeenBuildID { + delete(f.opens, key) + } + return nil + }).AnyTimes() + + opens.EXPECT().List(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, bounds storage.OpenIssueRange) ([]entity.OpenIssue, error) { + if err := f.fail("opens/list"); err != nil { + return nil, err + } + keys := make([]issueKey, 0) + for key := range f.opens { + if bounds.After.TaskID == "" || key.task > bounds.After.TaskID || (key.task == bounds.After.TaskID && key.unit > bounds.After.UnitID) { + keys = append(keys, key) + } + } + sortKeys(keys) + if len(keys) > bounds.Limit { + keys = keys[:bounds.Limit] + } + result := make([]entity.OpenIssue, 0, len(keys)) + for _, key := range keys { + result = append(result, f.opens[key]) + } + return result, nil + }).AnyTimes() + + logs.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, log entity.IssueLog) error { + if err := f.fail("logs/" + string(log.Event)); err != nil { + return err + } + key := logKey{issueKey{log.TaskID, log.UnitID}, log.BuildID, log.Event} + if _, ok := f.logs[key]; ok { + return storage.ErrAlreadyExists + } + f.logs[key] = log + return nil + }).AnyTimes() + + progress.EXPECT().Get(gomock.Any()).DoAndReturn(func(context.Context) (entity.IssueTrackingState, error) { + if err := f.fail("progress/get"); err != nil { + return entity.IssueTrackingState{}, err + } + if !f.hasProgress { + return entity.IssueTrackingState{}, storage.ErrNotFound + } + return f.progress, nil + }).AnyTimes() + + progress.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, state entity.IssueTrackingState) error { + if err := f.fail("progress/create"); err != nil { + return err + } + if f.createRace { + f.createRace = false + f.hasProgress = true + f.progress = entity.IssueTrackingState{RequestID: "11", BuildID: "build/11", NotificationID: "other", Phase: entity.IssueTrackingPhaseRefreshed, Version: 1} + return storage.ErrAlreadyExists + } + if f.hasProgress { + return storage.ErrAlreadyExists + } + f.progress = state + f.hasProgress = true + return nil + }).AnyTimes() + + progress.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, state entity.IssueTrackingState, oldVersion, newVersion int32) error { + if state.IsPending() { + if err := f.fail("progress/start"); err != nil { + return err + } + } + if err := f.fail("progress/update"); err != nil { + return err + } + if f.updateRace && state.IsPending() { + f.updateRace = false + f.progress = entity.IssueTrackingState{RequestID: "11", BuildID: "build/11", NotificationID: "other", Phase: entity.IssueTrackingPhaseRefreshed, Version: oldVersion + 1} + return storage.ErrVersionMismatch + } + if !f.hasProgress || f.progress.Version != oldVersion { + return storage.ErrVersionMismatch + } + if !state.IsPending() { + if err := f.fail("progress/complete"); err != nil { + return err + } + } + state.Version = newVersion + f.progress = state + return nil + }).AnyTimes() + + f.controller = NewController(zap.NewNop().Sugar(), tally.NoopScope, stores, stovepipemq.TopicKeyIssueTrack, "test-issuetrack") + f.controller.nowMs = func() int64 { return 1000 } + return f +} + +func sortKeys(keys []issueKey) { + sort.Slice(keys, func(i, j int) bool { + if keys[i].task != keys[j].task { + return keys[i].task < keys[j].task + } + return keys[i].unit < keys[j].unit + }) +} + +func (f *fixture) fail(operation string) error { + if f.failures[operation] > 0 { + f.failures[operation]-- + return assert.AnError + } + return nil +} + +func (f *fixture) source(buildID, requestID string, status entity.BuildStatus) { + f.builds[buildID] = entity.Build{ID: buildID, RequestID: requestID, Status: status, Version: 1} + request := entity.Request{ID: requestID, Queue: testQueue, State: entity.RequestStateProcessing} + if status.IsTerminal() { + request.TerminalBuildID = buildID + switch status { + case entity.BuildStatusSucceeded: + request.State = entity.RequestStateSucceeded + case entity.BuildStatusCancelled: + request.State = entity.RequestStateCancelled + default: + request.State = entity.RequestStateFailed + } + } + f.requests[requestID] = request +} + +func (f *fixture) reportIssue(buildID, task, unit string) { + if f.buildIssues[buildID] == nil { + f.buildIssues[buildID] = map[issueKey]entity.BuildIssue{} + } + f.buildIssues[buildID][issueKey{task, unit}] = entity.BuildIssue{TaskID: task, UnitID: unit} +} + +func (f *fixture) seed(task, unit, buildID string) { + f.opens[issueKey{task, unit}] = entity.OpenIssue{TaskID: task, UnitID: unit, FirstSeenBuildID: buildID} + f.logs[logKey{issueKey{task, unit}, buildID, entity.IssueEventOpened}] = entity.IssueLog{TaskID: task, UnitID: unit, BuildID: buildID, Event: entity.IssueEventOpened, TimestampMs: 1} +} + +func (f *fixture) startPass(buildID string, phase entity.IssueTrackingPhase) { + f.t.Helper() + f.hasProgress = true + f.progress = entity.IssueTrackingState{ + RequestID: f.builds[buildID].RequestID, BuildID: buildID, + NotificationID: "notification/" + buildID, Phase: phase, Version: 1, + } +} + +func (f *fixture) batch() (entity.IssueTrackingState, error) { + f.t.Helper() + return f.controller.reconcileBatch(context.Background(), f.store, testQueue, f.progress) +} + +func TestRefreshCheckpointsOneBatch(t *testing.T) { + for _, count := range []int{0, 99, 100, 103} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + f := newFixture(t) + f.source("build/10", "10", entity.BuildStatusRunning) + for i := 0; i < count; i++ { + f.reportIssue("build/10", "task", fmt.Sprintf("unit/%03d", i)) + } + f.startPass("build/10", entity.IssueTrackingPhaseRefreshing) + checkpoint, err := f.batch() + require.NoError(t, err) + require.Equal(t, checkpoint, f.progress) + require.Len(t, f.opens, min(count, _issueBatchSize)) + if count >= _issueBatchSize { + require.True(t, checkpoint.IsPending()) + require.Equal(t, "unit/099", checkpoint.AfterUnitID) + checkpoint, err = f.batch() + require.NoError(t, err) + } + require.Equal(t, entity.IssueTrackingPhaseRefreshed, checkpoint.Phase) + require.Len(t, f.opens, count) + require.Len(t, f.logs, count) + }) + } +} + +func TestFinalizationMergesBothKeyRangesWithOneCursor(t *testing.T) { + f := newFixture(t) + f.source("new", "10", entity.BuildStatusFailed) + for i := 0; i < 200; i++ { + f.seed("task", fmt.Sprintf("unit/%03d", i), "old") + } + for i := 100; i < 300; i++ { + f.reportIssue("new", "task", fmt.Sprintf("unit/%03d", i)) + } + f.startPass("new", entity.IssueTrackingPhaseFinalizing) + + for _, last := range []string{"unit/099", "unit/199", "unit/299"} { + checkpoint, err := f.batch() + require.NoError(t, err) + require.Equal(t, last, checkpoint.AfterUnitID) + require.True(t, checkpoint.IsPending()) + } + checkpoint, err := f.batch() + require.NoError(t, err) + require.Equal(t, entity.IssueTrackingPhaseFinalized, checkpoint.Phase) + require.Len(t, f.opens, 200) + require.Len(t, f.logs, 400) + for i := 0; i < 300; i++ { + key := issueKey{"task", fmt.Sprintf("unit/%03d", i)} + if i < 100 { + require.NotContains(t, f.opens, key) + } else if i < 200 { + require.Equal(t, "old", f.opens[key].FirstSeenBuildID) + } else { + require.Equal(t, "new", f.opens[key].FirstSeenBuildID) + } + } +} + +func TestCombinedShortReadsStillNeedTwoBatches(t *testing.T) { + f := newFixture(t) + f.source("new", "10", entity.BuildStatusFailed) + for i := 0; i < 99; i++ { + f.reportIssue("new", "task", fmt.Sprintf("unit/%03d", 2*i)) + f.seed("task", fmt.Sprintf("unit/%03d", 2*i+1), "old") + } + f.startPass("new", entity.IssueTrackingPhaseFinalizing) + + checkpoint, err := f.batch() + require.NoError(t, err) + require.True(t, checkpoint.IsPending()) + require.Equal(t, "unit/099", checkpoint.AfterUnitID) + require.Len(t, f.opens, 99) // Fifty opened issues and forty-nine not yet closed. + require.Len(t, f.logs, 199) + + checkpoint, err = f.batch() + require.NoError(t, err) + require.Equal(t, entity.IssueTrackingPhaseFinalized, checkpoint.Phase) + require.Equal(t, "unit/197", checkpoint.AfterUnitID) + require.Len(t, f.opens, 99) + require.Len(t, f.logs, 297) + for key, issue := range f.opens { + require.Contains(t, f.buildIssues["new"], key) + require.Equal(t, "new", issue.FirstSeenBuildID) + } +} + +func TestMergedPagesProcessSharedKeysOnlyOnce(t *testing.T) { + f := newFixture(t) + f.source("new", "10", entity.BuildStatusFailed) + for i := 0; i < _issueBatchSize; i++ { + unit := fmt.Sprintf("unit/%03d", i) + f.seed("task", unit, "old") + f.reportIssue("new", "task", unit) + } + f.startPass("new", entity.IssueTrackingPhaseFinalizing) + checkpoint, err := f.batch() + require.NoError(t, err) + require.Equal(t, "unit/099", checkpoint.AfterUnitID) + require.True(t, checkpoint.IsPending()) + checkpoint, err = f.batch() + require.NoError(t, err) + require.False(t, checkpoint.IsPending()) + require.Len(t, f.opens, _issueBatchSize) + require.Len(t, f.logs, _issueBatchSize) +} + +func TestTaskWideIssueAndDeletedCursorRemainTraversable(t *testing.T) { + f := newFixture(t) + f.source("new", "10", entity.BuildStatusSucceeded) + f.seed("task", "", "old") + for i := 0; i < _issueBatchSize; i++ { + f.seed("task", fmt.Sprintf("unit/%03d", i), "old") + } + f.startPass("new", entity.IssueTrackingPhaseFinalizing) + checkpoint, err := f.batch() + require.NoError(t, err) + require.True(t, checkpoint.IsPending()) + require.Len(t, f.opens, 1) + checkpoint, err = f.batch() + require.NoError(t, err) + require.False(t, checkpoint.IsPending()) + require.Empty(t, f.opens) + require.Len(t, f.logs, 2*(_issueBatchSize+1)) +} + +func TestBatchFailureDoesNotAdvanceCheckpoint(t *testing.T) { + for _, operation := range []string{"opens/create", "logs/opened", "logs/resolved", "opens/delete", "progress/update"} { + t.Run(operation, func(t *testing.T) { + f := newFixture(t) + f.source("new", "10", entity.BuildStatusFailed) + f.reportIssue("new", "a", "unit") + f.seed("z", "unit", "old") + f.startPass("new", entity.IssueTrackingPhaseFinalizing) + before := f.progress + f.failures[operation] = 1 + _, err := f.batch() + require.ErrorIs(t, err, assert.AnError) + require.Equal(t, before, f.progress) + checkpoint, err := f.batch() + require.NoError(t, err) + require.Equal(t, entity.IssueTrackingPhaseFinalized, checkpoint.Phase) + require.Len(t, f.opens, 1) + require.Contains(t, f.opens, issueKey{"a", "unit"}) + require.Len(t, f.logs, 3) + }) + } +} + +func TestCanceledFinalizationKeepsExistingIssuesOpen(t *testing.T) { + f := newFixture(t) + f.source("new", "10", entity.BuildStatusCancelled) + f.seed("old", "unit", "old") + f.reportIssue("new", "new", "unit") + f.startPass("new", entity.IssueTrackingPhaseFinalizing) + f.failures["opens/list"] = 1 + checkpoint, err := f.batch() + require.NoError(t, err) + require.Equal(t, entity.IssueTrackingPhaseFinalized, checkpoint.Phase) + require.Len(t, f.opens, 2) + require.Len(t, f.logs, 2) +} + +func TestReadFailureCannotCloseIssuesOrAdvanceCheckpoint(t *testing.T) { + for _, operation := range []string{"buildIssues/list", "opens/list", "buildIssues/get"} { + t.Run(operation, func(t *testing.T) { + f := newFixture(t) + f.source("new", "10", entity.BuildStatusSucceeded) + f.seed("old", "unit", "old") + f.startPass("new", entity.IssueTrackingPhaseFinalizing) + before := f.progress + f.failures[operation] = 1 + _, err := f.batch() + require.ErrorIs(t, err, assert.AnError) + require.Equal(t, before, f.progress) + require.Len(t, f.opens, 1) + require.Len(t, f.logs, 1) + }) + } +} + +func TestFreshRefreshFindsBuildIssuesInsertedBehindEarlierCursor(t *testing.T) { + f := newFixture(t) + f.source("new", "10", entity.BuildStatusRunning) + for i := 0; i < _issueBatchSize+1; i++ { + f.reportIssue("new", "task", fmt.Sprintf("unit/%03d", i)) + } + f.startPass("new", entity.IssueTrackingPhaseRefreshing) + _, err := f.batch() + require.NoError(t, err) + f.reportIssue("new", "a", "late") + checkpoint, err := f.batch() + require.NoError(t, err) + require.False(t, checkpoint.IsPending()) + require.NotContains(t, f.opens, issueKey{"a", "late"}) + + f.progress.Phase = entity.IssueTrackingPhaseRefreshing + f.progress.NotificationID = "next-refresh" + f.progress.AfterTaskID, f.progress.AfterUnitID = "", "" + _, err = f.batch() + require.NoError(t, err) + require.Contains(t, f.opens, issueKey{"a", "late"}) +} diff --git a/stovepipe/core/messagequeue/README.md b/stovepipe/core/messagequeue/README.md index a4f05b33d..473b0c01a 100644 --- a/stovepipe/core/messagequeue/README.md +++ b/stovepipe/core/messagequeue/README.md @@ -14,10 +14,10 @@ Payloads are defined in proto3 (`proto/`, generated into `protopb/`) and seriali ## Issue tracking handoff -`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`. +`IssueTrack` binds to `TopicKeyIssueTrack` and identifies a persisted build and its queue. Notifications are partitioned by queue so both refresh 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. -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. A final notification attests that its persisted observations are complete and authoritative for the queue's relevant validation scope; a producer unable to establish that must not request absence-based resolution. The reconciler rejects older requests and ignores refresh notifications after finalization. Notification identities must not suppress later refreshes or finalization. Consumers must tolerate duplicate publications. 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. +The [issue-tracking controller](../../controller/issuetrack/README.md) implements reconciliation and operational progress. No publisher or consumer registration is enabled in the reference host; runtime handoff, recovery wiring, and user-facing summary materialization remain follow-up work. diff --git a/stovepipe/core/messagequeue/proto/issuetrack.proto b/stovepipe/core/messagequeue/proto/issuetrack.proto index 52acce9f5..d9ac6e415 100644 --- a/stovepipe/core/messagequeue/proto/issuetrack.proto +++ b/stovepipe/core/messagequeue/proto/issuetrack.proto @@ -34,6 +34,8 @@ message IssueTrack { string queue_name = 2; // finalize requests final reconciliation after successful terminal detection // and persistence, including when no failures were detected. + // Persisted observations must be the complete authoritative failure set for + // the queue's relevant validation scope before absence may imply resolution. // Otherwise the notification permits opening issues, not resolution by absence. bool finalize = 3; } diff --git a/stovepipe/core/messagequeue/protopb/issuetrack.pb.go b/stovepipe/core/messagequeue/protopb/issuetrack.pb.go index 38a282c26..4491da397 100644 --- a/stovepipe/core/messagequeue/protopb/issuetrack.pb.go +++ b/stovepipe/core/messagequeue/protopb/issuetrack.pb.go @@ -47,6 +47,8 @@ type IssueTrack struct { QueueName string `protobuf:"bytes,2,opt,name=queue_name,json=queueName,proto3" json:"queue_name,omitempty"` // finalize requests final reconciliation after successful terminal detection // and persistence, including when no failures were detected. + // Persisted observations must be the complete authoritative failure set for + // the queue's relevant validation scope before absence may imply resolution. // Otherwise the notification permits opening issues, not resolution by absence. Finalize bool `protobuf:"varint,3,opt,name=finalize,proto3" json:"finalize,omitempty"` unknownFields protoimpl.UnknownFields