Skip to content

Commit 38196cf

Browse files
committed
feat(stovepipe): Add issue-tracking controller
1 parent 21015cf commit 38196cf

9 files changed

Lines changed: 1167 additions & 5 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = [
6+
"issuetrack.go",
7+
"reconcile.go",
8+
],
9+
importpath = "github.com/uber/submitqueue/stovepipe/controller/issuetrack",
10+
visibility = ["//visibility:public"],
11+
deps = [
12+
"//platform/base/id:go_default_library",
13+
"//platform/base/messagequeue:go_default_library",
14+
"//platform/consumer:go_default_library",
15+
"//platform/metrics:go_default_library",
16+
"//stovepipe/core/loader:go_default_library",
17+
"//stovepipe/core/messagequeue:go_default_library",
18+
"//stovepipe/entity:go_default_library",
19+
"//stovepipe/extension/storage:go_default_library",
20+
"@com_github_uber_go_tally//:go_default_library",
21+
"@org_uber_go_zap//:go_default_library",
22+
],
23+
)
24+
25+
go_test(
26+
name = "go_default_test",
27+
srcs = ["issuetrack_test.go"],
28+
embed = [":go_default_library"],
29+
deps = [
30+
"//platform/base/messagequeue:go_default_library",
31+
"//platform/consumer/mock:go_default_library",
32+
"//platform/publish:go_default_library",
33+
"//stovepipe/core/messagequeue:go_default_library",
34+
"//stovepipe/entity:go_default_library",
35+
"//stovepipe/extension/storage:go_default_library",
36+
"//stovepipe/extension/storage/mock:go_default_library",
37+
"@com_github_stretchr_testify//assert:go_default_library",
38+
"@com_github_stretchr_testify//require:go_default_library",
39+
"@com_github_uber_go_tally//:go_default_library",
40+
"@org_uber_go_mock//gomock:go_default_library",
41+
"@org_uber_go_zap//:go_default_library",
42+
],
43+
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Issue tracking
2+
3+
`issuetrack.Controller` consumes `IssueTrack` notifications in a single queue partition. It alone maintains operational `OpenIssue` membership and records `IssueLog` transitions; build detection and immutable per-build observations remain in `buildanalysis`. The controller rejects messages whose partition key differs from the payload queue. Producers and consumers must preserve per-queue serialization; the progress row is not a distributed lock or a substitute for that delivery contract.
4+
5+
## Reconciliation
6+
7+
A running notification traverses its build's observations in bounded task/unit batches. A missing issue is opened using this build as its first-seen build. Repeated observations preserve existing openings and do not create another transition. Final reconciliation also traverses open issues and resolves those absent from the source build. Cancelled builds may add confirmed observations but never establish resolution. Final notifications must name the request's winning terminal build; duplicate-build losers are ignored.
8+
9+
The final-notification contract is an input requirement, not something terminal status alone proves: detection must have succeeded, its persisted observations must be complete and authoritative for the queue's relevant validation scope, and in-flight analysis must not add unaccounted results after the final handoff. Accumulated observations are not automatically a current failure snapshot. A producer that cannot satisfy those requirements must not request absence-based resolution. No producer or consumer is wired by this implementation; the production handoff and recovery policy remain follow-up work.
10+
11+
## Source ordering and interrupted passes
12+
13+
`IssueTrackingStore` retains one progress row per queue, including the source request, build, phase, and optimistic version. Request IDs are compared through `CompareRequestID`; provider build IDs and timestamps are not order keys. An older request cannot modify current issues. Later running notifications and duplicate finals for an already-finalized request are ignored. Same-request observation notifications remain eligible until finalization. The progress row remains even when there are no open issues.
14+
15+
Before applying any issue writes, the controller conditionally records a refreshing or finalizing phase. It records the completed phase only after issue and history writes succeed. A subsequent delivery first finishes pending work, including when the original delivery was retried or dead-lettered; only then may it admit a newer source. A permanent pending failure therefore needs operational repair rather than silently skipping a partially applied transition. Controller code owns version arithmetic; storage performs guarded replacement only.
16+
17+
Opening history is repaired on replay if an open row was created before its log write failed. Resolution is retained before deleting the matching opening, so failed history persistence leaves the issue available for retry. Duplicate transition identities preserve their original timestamps. Replaying a whole pass avoids a multi-row transaction or a durable cursor over mutable membership. The operational progress and accepted writes are serialized, not an atomic database snapshot.
18+
19+
This controller does not publish fix requests, expose a user-facing issue read model, or materialize `IssueSummary`. Those are separate responsibilities. Lifecycle history is retained here; a log-derived summary/materializer can be added without changing the observation handoff or the source-order guard.
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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+
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
28+
"github.com/uber/submitqueue/platform/consumer"
29+
"github.com/uber/submitqueue/platform/metrics"
30+
"github.com/uber/submitqueue/stovepipe/core/loader"
31+
stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue"
32+
"github.com/uber/submitqueue/stovepipe/entity"
33+
"github.com/uber/submitqueue/stovepipe/extension/storage"
34+
"go.uber.org/zap"
35+
)
36+
37+
// Each read is bounded; every pass still traverses the entire relevant key range.
38+
const _issueBatchSize = 100
39+
40+
// Controller is the queue-serialized writer of open issues and issue transitions.
41+
// Accepted pending work is replayed before a newer source can change issue state.
42+
type Controller struct {
43+
logger *zap.SugaredLogger
44+
metricsScope tally.Scope
45+
stores storage.Factory
46+
topicKey consumer.TopicKey
47+
consumerGroup string
48+
nowMs func() int64
49+
}
50+
51+
func NewController(
52+
logger *zap.SugaredLogger,
53+
scope tally.Scope,
54+
stores storage.Factory,
55+
topicKey consumer.TopicKey,
56+
consumerGroup string,
57+
) *Controller {
58+
return &Controller{
59+
logger: logger.Named("issuetrack_controller"),
60+
metricsScope: scope.SubScope("issuetrack_controller"),
61+
stores: stores,
62+
topicKey: topicKey,
63+
consumerGroup: consumerGroup,
64+
nowMs: func() int64 { return time.Now().UnixMilli() },
65+
}
66+
}
67+
68+
func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (retErr error) {
69+
op := metrics.Begin(c.metricsScope, "process", metrics.LongLatencyBuckets, metrics.TagsFromContext(ctx)...)
70+
defer func() { op.Complete(retErr) }()
71+
72+
msg := delivery.Message()
73+
notification := &stovepipemq.IssueTrack{}
74+
if err := stovepipemq.Unmarshal(msg.Payload, notification); err != nil {
75+
return fmt.Errorf("deserialize issue tracking notification: %w", err)
76+
}
77+
78+
queue := notification.GetQueueName()
79+
if err := entityqueue.ValidatePayloadQueue(msg, queue); err != nil {
80+
return fmt.Errorf("invalid message identity: %w", err)
81+
}
82+
if queue == "" || notification.GetBuildId() == "" {
83+
return fmt.Errorf("issue tracking requires queue and build identifiers")
84+
}
85+
if msg.PartitionKey != queue {
86+
return fmt.Errorf("issue tracking must be partitioned by queue %q", queue)
87+
}
88+
89+
store, err := c.stores.For(storage.Config{QueueName: queue})
90+
if err != nil {
91+
return fmt.Errorf("resolve issue tracking storage: %w", err)
92+
}
93+
94+
build, request, err := loadBuildAndRequest(ctx, store, queue, notification.GetBuildId())
95+
if err != nil {
96+
return err
97+
}
98+
if request.TerminalBuildID != "" && request.TerminalBuildID != build.ID {
99+
return nil
100+
}
101+
102+
return c.reconcileBuild(ctx, store, build, request, notification.GetFinalize())
103+
}
104+
105+
func (c *Controller) reconcileBuild(ctx context.Context, store storage.Storage, build entity.Build, request entity.Request, finalize bool) error {
106+
trackingStore := store.GetIssueTrackingStore()
107+
108+
for {
109+
if err := ctx.Err(); err != nil {
110+
return err
111+
}
112+
113+
progress, err := trackingStore.Get(ctx)
114+
hasProgress := err == nil
115+
if err != nil && !errors.Is(err, storage.ErrNotFound) {
116+
return fmt.Errorf("load issue tracking progress: %w", err)
117+
}
118+
119+
if hasProgress && progress.IsPending() {
120+
if err := c.resumeReconciliation(ctx, store, request.Queue, progress); err != nil {
121+
return err
122+
}
123+
124+
sameBuild := progress.RequestID == request.ID && progress.BuildID == build.ID
125+
needsFinalPass := finalize && progress.Phase == entity.IssueTrackingPhaseRefreshing
126+
if sameBuild && !needsFinalPass {
127+
return nil
128+
}
129+
continue
130+
}
131+
132+
if hasProgress {
133+
order, err := entity.CompareRequestID(request.ID, progress.RequestID)
134+
if err != nil {
135+
return err
136+
}
137+
if order < 0 || (order == 0 && progress.Phase == entity.IssueTrackingPhaseFinalized) {
138+
c.logger.Debugw("ignoring superseded issue analysis", "queue", request.Queue,
139+
"build_id", build.ID, "request_id", request.ID, "current_request_id", progress.RequestID)
140+
return nil
141+
}
142+
}
143+
144+
pending := entity.IssueTrackingState{
145+
RequestID: request.ID,
146+
BuildID: build.ID,
147+
Phase: entity.IssueTrackingPhaseRefreshing,
148+
Version: 1,
149+
}
150+
if finalize {
151+
if err := validateFinalBuild(build, request); err != nil {
152+
return err
153+
}
154+
pending.Phase = entity.IssueTrackingPhaseFinalizing
155+
}
156+
157+
if !hasProgress {
158+
err = trackingStore.Create(ctx, pending)
159+
if errors.Is(err, storage.ErrAlreadyExists) {
160+
continue
161+
}
162+
} else {
163+
pending.Version = progress.Version + 1
164+
err = trackingStore.Update(ctx, pending, progress.Version, pending.Version)
165+
if errors.Is(err, storage.ErrVersionMismatch) {
166+
continue
167+
}
168+
}
169+
if err != nil {
170+
return fmt.Errorf("start issue reconciliation: %w", err)
171+
}
172+
173+
return c.completeReconciliation(ctx, store, build, request, pending)
174+
}
175+
}
176+
177+
func (c *Controller) resumeReconciliation(ctx context.Context, store storage.Storage, queue string, progress entity.IssueTrackingState) error {
178+
build, request, err := loadBuildAndRequest(ctx, store, queue, progress.BuildID)
179+
if err != nil {
180+
return err
181+
}
182+
183+
return c.completeReconciliation(ctx, store, build, request, progress)
184+
}
185+
186+
func loadBuildAndRequest(ctx context.Context, store storage.Storage, queue, buildID string) (entity.Build, entity.Request, error) {
187+
build, err := loader.ByID(ctx, buildID, store.GetBuildStore().Get, "build")
188+
if err != nil {
189+
return entity.Build{}, entity.Request{}, err
190+
}
191+
192+
request, err := loader.ByID(ctx, build.RequestID, store.GetRequestStore().Get, "request")
193+
if err != nil {
194+
return entity.Build{}, entity.Request{}, err
195+
}
196+
197+
if request.Queue != queue {
198+
return entity.Build{}, entity.Request{}, fmt.Errorf("issue tracking request belongs to another queue")
199+
}
200+
if err := id.Validate(request.ID); err != nil {
201+
return entity.Build{}, entity.Request{}, fmt.Errorf("invalid issue tracking request ID: %w", err)
202+
}
203+
204+
return build, request, nil
205+
}
206+
207+
func validateFinalBuild(build entity.Build, request entity.Request) error {
208+
if !build.Status.IsTerminal() || request.TerminalBuildID != build.ID {
209+
return fmt.Errorf("final issue reconciliation requires the request's terminal build")
210+
}
211+
return nil
212+
}
213+
214+
func (c *Controller) Name() string { return "issuetrack" }
215+
func (c *Controller) TopicKey() consumer.TopicKey { return c.topicKey }
216+
func (c *Controller) ConsumerGroup() string { return c.consumerGroup }
217+
218+
var _ consumer.Controller = (*Controller)(nil)

0 commit comments

Comments
 (0)