Skip to content
Draft
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
35 changes: 35 additions & 0 deletions stovepipe/controller/issuetrack/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
],
)
19 changes: 19 additions & 0 deletions stovepipe/controller/issuetrack/README.md
Original file line number Diff line number Diff line change
@@ -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.
253 changes: 253 additions & 0 deletions stovepipe/controller/issuetrack/issuetrack.go
Original file line number Diff line number Diff line change
@@ -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 }
Loading