Skip to content

Commit eeddbdd

Browse files
committed
feat(stovepipe): Reconcile issues in batches
1 parent 527a344 commit eeddbdd

7 files changed

Lines changed: 814 additions & 3 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["issuetrack.go"],
6+
importpath = "github.com/uber/submitqueue/stovepipe/controller/issuetrack",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//platform/base/id:go_default_library",
10+
"//platform/consumer:go_default_library",
11+
"//platform/metrics:go_default_library",
12+
"//stovepipe/core/loader:go_default_library",
13+
"//stovepipe/entity:go_default_library",
14+
"//stovepipe/extension/storage:go_default_library",
15+
"@com_github_uber_go_tally//:go_default_library",
16+
"@org_uber_go_zap//:go_default_library",
17+
],
18+
)
19+
20+
go_test(
21+
name = "go_default_test",
22+
srcs = ["issuetrack_test.go"],
23+
embed = [":go_default_library"],
24+
deps = [
25+
"//stovepipe/core/messagequeue:go_default_library",
26+
"//stovepipe/entity:go_default_library",
27+
"//stovepipe/extension/storage:go_default_library",
28+
"//stovepipe/extension/storage/mock:go_default_library",
29+
"@com_github_stretchr_testify//assert:go_default_library",
30+
"@com_github_stretchr_testify//require:go_default_library",
31+
"@com_github_uber_go_tally//:go_default_library",
32+
"@org_uber_go_mock//gomock:go_default_library",
33+
"@org_uber_go_zap//:go_default_library",
34+
],
35+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Issue reconciliation
2+
3+
The controller's batch operation advances one persisted issue-tracking pass by at most 100 distinct task/unit keys. A refresh scans the source build's observations and opens missing issues. Finalization merges that ordered range with the queue's ordered open-issue range: observed keys are kept or opened, and open-only keys are resolved. Both ranges use the same exclusive cursor, so no separate opening/resolution scan state is required. Cancelled builds only add observations.
4+
5+
Each read is bounded. When a page fills the limit, another batch may be needed to establish that the range is exhausted. After all writes succeed, the controller conditionally replaces progress with the last fully processed key and incremented version. Exhausted passes become refreshed or finalized. Failure before checkpoint persistence leaves the old cursor available for replay; opening/history writes and resolution-before-deletion are idempotent. Each pass reloads its persisted source and final passes require the request's winning terminal build.
6+
7+
Refresh scans are not snapshots: observations inserted behind a cursor are discovered by a later refresh starting from the beginning. Finalization requires successful complete detection and persistence before its handoff; terminal build status alone does not establish completeness. Final reconciliation uses the build's accumulated issue set, not merely its latest detector response.
8+
9+
This layer does not receive messages, publish repair requests, register consumers, or implement terminal recovery. Delivery orchestration is added separately. A future repair handoff must persist its prerequisites and publish before advancing the checkpoint past that obligation.
10+
11+
## Ownership prerequisite
12+
13+
All issue membership and history writes require one active writer for a queue. A progress CAS is not a lock or a fence for those other records. In particular, a worker paused before creating an opening can resume after another worker finalized a newer build, recreate the old opening, and only then lose its checkpoint CAS. Normal per-partition serialization does not by itself prove safety during lease transfer. Worker fencing must be established before enabling this controller in a host; checkpointing does not resolve that rollout blocker.
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package issuetrack applies build observations to operational open issues and lifecycle history.
16+
// All deliveries for one queue must share its partition and consumer group.
17+
package issuetrack
18+
19+
import (
20+
"context"
21+
"errors"
22+
"fmt"
23+
"time"
24+
25+
"github.com/uber-go/tally"
26+
"github.com/uber/submitqueue/platform/base/id"
27+
"github.com/uber/submitqueue/platform/consumer"
28+
"github.com/uber/submitqueue/platform/metrics"
29+
"github.com/uber/submitqueue/stovepipe/core/loader"
30+
"github.com/uber/submitqueue/stovepipe/entity"
31+
"github.com/uber/submitqueue/stovepipe/extension/storage"
32+
"go.uber.org/zap"
33+
)
34+
35+
// Each batch applies at most this many distinct issue keys.
36+
const _issueBatchSize = 100
37+
38+
// Controller is the queue-serialized writer of open issues and issue transitions.
39+
// Accepted pending work is replayed before a newer source can change issue state.
40+
type Controller struct {
41+
logger *zap.SugaredLogger
42+
metricsScope tally.Scope
43+
stores storage.Factory
44+
topicKey consumer.TopicKey
45+
consumerGroup string
46+
nowMs func() int64
47+
}
48+
49+
func NewController(
50+
logger *zap.SugaredLogger,
51+
scope tally.Scope,
52+
stores storage.Factory,
53+
topicKey consumer.TopicKey,
54+
consumerGroup string,
55+
) *Controller {
56+
return &Controller{
57+
logger: logger.Named("issuetrack_controller"),
58+
metricsScope: scope.SubScope("issuetrack_controller"),
59+
stores: stores,
60+
topicKey: topicKey,
61+
consumerGroup: consumerGroup,
62+
nowMs: func() int64 { return time.Now().UnixMilli() },
63+
}
64+
}
65+
66+
func (c *Controller) applyReconciliationBatch(ctx context.Context, store storage.Storage, queue string, progress entity.IssueTrackingState) (checkpoint entity.IssueTrackingState, retErr error) {
67+
op := metrics.Begin(c.metricsScope, "batch", metrics.LongLatencyBuckets, metrics.TagsFromContext(ctx)...)
68+
defer func() { op.Complete(retErr) }()
69+
70+
build, request, err := loadBuildAndRequest(ctx, store, queue, progress.BuildID)
71+
if err != nil {
72+
return entity.IssueTrackingState{}, err
73+
}
74+
if request.ID != progress.RequestID || !progress.IsPending() {
75+
return entity.IssueTrackingState{}, fmt.Errorf("issue batch requires pending work for its source request")
76+
}
77+
finalizing := progress.Phase == entity.IssueTrackingPhaseFinalizing
78+
if finalizing {
79+
if err := validateFinalBuild(build, request); err != nil {
80+
return entity.IssueTrackingState{}, err
81+
}
82+
}
83+
84+
observations, err := store.GetBuildIssueStore().List(ctx, build.ID, storage.BuildIssueRange{
85+
After: storage.BuildIssueCursor{TaskID: progress.AfterTaskID, UnitID: progress.AfterUnitID},
86+
Limit: _issueBatchSize,
87+
})
88+
if err != nil {
89+
return entity.IssueTrackingState{}, fmt.Errorf("list build observations: %w", err)
90+
}
91+
92+
var openIssues []entity.OpenIssue
93+
if finalizing && build.Status != entity.BuildStatusCancelled {
94+
openIssues, err = store.GetOpenIssueStore().List(ctx, storage.OpenIssueRange{
95+
After: storage.OpenIssueCursor{TaskID: progress.AfterTaskID, UnitID: progress.AfterUnitID},
96+
Limit: _issueBatchSize,
97+
})
98+
if err != nil {
99+
return entity.IssueTrackingState{}, fmt.Errorf("list open issues: %w", err)
100+
}
101+
}
102+
103+
next := progress
104+
observedIndex, openIndex := 0, 0
105+
for processed := 0; processed < _issueBatchSize && (observedIndex < len(observations) || openIndex < len(openIssues)); processed++ {
106+
if err := ctx.Err(); err != nil {
107+
return entity.IssueTrackingState{}, err
108+
}
109+
110+
useObservation := openIndex == len(openIssues)
111+
if observedIndex < len(observations) && openIndex < len(openIssues) {
112+
observed, open := observations[observedIndex], openIssues[openIndex]
113+
useObservation = compareIssueKeys(observed.TaskID, observed.UnitID, open.TaskID, open.UnitID) <= 0
114+
}
115+
if useObservation {
116+
issue := observations[observedIndex]
117+
if err := c.openObservedIssue(ctx, store, build.ID, issue); err != nil {
118+
return entity.IssueTrackingState{}, err
119+
}
120+
if openIndex < len(openIssues) && openIssues[openIndex].TaskID == issue.TaskID && openIssues[openIndex].UnitID == issue.UnitID {
121+
openIndex++
122+
}
123+
observedIndex++
124+
next.AfterTaskID, next.AfterUnitID = issue.TaskID, issue.UnitID
125+
} else {
126+
issue := openIssues[openIndex]
127+
if err := c.resolveIssueIfAbsent(ctx, store, build.ID, issue); err != nil {
128+
return entity.IssueTrackingState{}, err
129+
}
130+
openIndex++
131+
next.AfterTaskID, next.AfterUnitID = issue.TaskID, issue.UnitID
132+
}
133+
}
134+
135+
// Full pages may hide more keys; an additional empty read safely establishes completion.
136+
complete := observedIndex == len(observations) && openIndex == len(openIssues) &&
137+
len(observations) < _issueBatchSize && len(openIssues) < _issueBatchSize
138+
if complete {
139+
next.Phase = entity.IssueTrackingPhaseRefreshed
140+
if finalizing {
141+
next.Phase = entity.IssueTrackingPhaseFinalized
142+
}
143+
}
144+
145+
newVersion := progress.Version + 1
146+
if err := store.GetIssueTrackingStore().Update(ctx, next, progress.Version, newVersion); err != nil {
147+
return entity.IssueTrackingState{}, fmt.Errorf("checkpoint issue reconciliation: %w", err)
148+
}
149+
next.Version = newVersion
150+
return next, nil
151+
}
152+
153+
func compareIssueKeys(taskID, unitID, otherTaskID, otherUnitID string) int {
154+
if taskID < otherTaskID {
155+
return -1
156+
}
157+
if taskID > otherTaskID {
158+
return 1
159+
}
160+
if unitID < otherUnitID {
161+
return -1
162+
}
163+
if unitID > otherUnitID {
164+
return 1
165+
}
166+
return 0
167+
}
168+
169+
func (c *Controller) openObservedIssue(ctx context.Context, store storage.Storage, buildID string, issue entity.BuildIssue) error {
170+
openStore := store.GetOpenIssueStore()
171+
openIssue, err := openStore.Get(ctx, issue.TaskID, issue.UnitID)
172+
if err != nil && !errors.Is(err, storage.ErrNotFound) {
173+
return fmt.Errorf("read open issue: %w", err)
174+
}
175+
176+
if errors.Is(err, storage.ErrNotFound) {
177+
openIssue = entity.OpenIssue{
178+
TaskID: issue.TaskID,
179+
UnitID: issue.UnitID,
180+
FirstSeenBuildID: buildID,
181+
}
182+
err = openStore.Create(ctx, openIssue)
183+
if errors.Is(err, storage.ErrAlreadyExists) {
184+
openIssue, err = openStore.Get(ctx, issue.TaskID, issue.UnitID)
185+
}
186+
if err != nil {
187+
return fmt.Errorf("open observed issue: %w", err)
188+
}
189+
}
190+
191+
if openIssue.FirstSeenBuildID == buildID {
192+
return c.recordIssueEvent(ctx, store, buildID, entity.IssueEventOpened, issue.TaskID, issue.UnitID)
193+
}
194+
return nil
195+
}
196+
197+
func (c *Controller) resolveIssueIfAbsent(ctx context.Context, store storage.Storage, buildID string, issue entity.OpenIssue) error {
198+
_, err := store.GetBuildIssueStore().Get(ctx, buildID, issue.TaskID, issue.UnitID)
199+
if err == nil {
200+
return nil
201+
}
202+
if !errors.Is(err, storage.ErrNotFound) {
203+
return fmt.Errorf("check retained build observation: %w", err)
204+
}
205+
206+
// Record resolution before deletion so a failed history write leaves the issue retryable.
207+
if err := c.recordIssueEvent(ctx, store, buildID, entity.IssueEventResolved, issue.TaskID, issue.UnitID); err != nil {
208+
return err
209+
}
210+
if err := store.GetOpenIssueStore().Delete(ctx, issue); err != nil {
211+
return fmt.Errorf("resolve open issue: %w", err)
212+
}
213+
return nil
214+
}
215+
216+
func (c *Controller) recordIssueEvent(ctx context.Context, store storage.Storage, buildID string, event entity.IssueEvent, taskID, unitID string) error {
217+
log := entity.IssueLog{
218+
TaskID: taskID,
219+
UnitID: unitID,
220+
BuildID: buildID,
221+
Event: event,
222+
TimestampMs: c.nowMs(),
223+
}
224+
if err := store.GetIssueLogStore().Create(ctx, log); err != nil && !errors.Is(err, storage.ErrAlreadyExists) {
225+
return fmt.Errorf("record issue transition: %w", err)
226+
}
227+
return nil
228+
}
229+
230+
func loadBuildAndRequest(ctx context.Context, store storage.Storage, queue, buildID string) (entity.Build, entity.Request, error) {
231+
build, err := loader.ByID(ctx, buildID, store.GetBuildStore().Get, "build")
232+
if err != nil {
233+
return entity.Build{}, entity.Request{}, err
234+
}
235+
236+
request, err := loader.ByID(ctx, build.RequestID, store.GetRequestStore().Get, "request")
237+
if err != nil {
238+
return entity.Build{}, entity.Request{}, err
239+
}
240+
241+
if request.Queue != queue {
242+
return entity.Build{}, entity.Request{}, fmt.Errorf("issue tracking request belongs to another queue")
243+
}
244+
if err := id.Validate(request.ID); err != nil {
245+
return entity.Build{}, entity.Request{}, fmt.Errorf("invalid issue tracking request ID: %w", err)
246+
}
247+
248+
return build, request, nil
249+
}
250+
251+
func validateFinalBuild(build entity.Build, request entity.Request) error {
252+
if !build.Status.IsTerminal() || request.TerminalBuildID != build.ID {
253+
return fmt.Errorf("final issue reconciliation requires the request's terminal build")
254+
}
255+
return nil
256+
}
257+
258+
func (c *Controller) Name() string { return "issuetrack" }
259+
func (c *Controller) TopicKey() consumer.TopicKey { return c.topicKey }
260+
func (c *Controller) ConsumerGroup() string { return c.consumerGroup }

0 commit comments

Comments
 (0)