Skip to content

Commit ada2bb6

Browse files
authored
feat(stovepipe): Store issue-tracking progress (#930)
## Summary **What**: - Persist queue-scoped source ordering, reconciliation ownership, and exclusive issue cursors with guarded progress updates. - Keep storage point-keyed and leave runtime writers disabled. **Why**: - Resume interrupted batches without restarting their scans or losing the boundary after the last open issue disappears. - Distinguish the notification that owns a pass from another notification helping finish it. ## Test Plan - [x] Add unit tests. - [x] Verify checkpoint persistence, queue isolation, identifier boundaries, and competing guarded updates against MySQL. ## Revert Plan - Revert dependents first, then this PR. No runtime writer is enabled; the additive progress table can remain unused. ## Issue Links - [CODEM-607](https://linear.app/uber/issue/CODEM-607/build-analysis) ## Stack 1. #930 1. #932 1. #940 1. #939
1 parent 0352607 commit ada2bb6

19 files changed

Lines changed: 751 additions & 1 deletion

‎stovepipe/entity/BUILD.bazel‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ go_library(
77
"build_issue.go",
88
"ingest.go",
99
"issue_log.go",
10+
"issue_tracking_state.go",
1011
"list.go",
1112
"open_issue.go",
1213
"project_fact_by_degree.go",
@@ -35,6 +36,7 @@ go_test(
3536
srcs = [
3637
"build_test.go",
3738
"issue_log_test.go",
39+
"issue_tracking_state_test.go",
3840
"project_fact_by_degree_test.go",
3941
"queue_state_test.go",
4042
"request_id_test.go",
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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 entity
16+
17+
import (
18+
"fmt"
19+
20+
"github.com/uber/submitqueue/platform/base/id"
21+
)
22+
23+
// IssueTrackingPhase describes whether one source build's issue updates are pending or complete.
24+
type IssueTrackingPhase string
25+
26+
const (
27+
// IssueTrackingPhaseUnknown is the unset phase.
28+
IssueTrackingPhaseUnknown IssueTrackingPhase = ""
29+
// IssueTrackingPhaseRefreshing means reported build issues are being opened or kept open.
30+
IssueTrackingPhaseRefreshing IssueTrackingPhase = "refreshing"
31+
// IssueTrackingPhaseRefreshed means a refresh pass completed; later refresh notifications remain eligible.
32+
IssueTrackingPhaseRefreshed IssueTrackingPhase = "refreshed"
33+
// IssueTrackingPhaseFinalizing means final opening and closing updates are pending.
34+
IssueTrackingPhaseFinalizing IssueTrackingPhase = "finalizing"
35+
// IssueTrackingPhaseFinalized means final reconciliation completed; later refresh notifications are ignored.
36+
IssueTrackingPhaseFinalized IssueTrackingPhase = "finalized"
37+
)
38+
39+
// IssueTrackingState is the queue-scoped source-order boundary and resumable reconciliation state.
40+
type IssueTrackingState struct {
41+
// RequestID is the newest accepted source request, compared by ingest order.
42+
RequestID string `json:"request_id"`
43+
// BuildID identifies the source build whose pass is pending or most recently completed.
44+
BuildID string `json:"build_id"`
45+
// NotificationID identifies the message that owns this reconciliation pass.
46+
NotificationID string `json:"notification_id"`
47+
// AfterTaskID is the exclusive task cursor after fully processed issue keys.
48+
// Empty with an empty AfterUnitID starts a pass.
49+
AfterTaskID string `json:"after_task_id"`
50+
// AfterUnitID is the unit component of the exclusive cursor; empty is task-wide.
51+
AfterUnitID string `json:"after_unit_id"`
52+
// Phase distinguishes pending work from completed refresh or final passes.
53+
Phase IssueTrackingPhase `json:"phase"`
54+
// Version is the positive optimistic-lock version of this state.
55+
Version int32 `json:"version"`
56+
}
57+
58+
// IsPending reports whether the phase represents unfinished reconciliation.
59+
func (s IssueTrackingState) IsPending() bool {
60+
return s.Phase == IssueTrackingPhaseRefreshing || s.Phase == IssueTrackingPhaseFinalizing
61+
}
62+
63+
// Validate verifies the identity, phase, and version of a persisted state.
64+
func (s IssueTrackingState) Validate() error {
65+
if err := id.Validate(s.RequestID); err != nil {
66+
return fmt.Errorf("invalid issue tracking request ID: %w", err)
67+
}
68+
if s.BuildID == "" {
69+
return fmt.Errorf("issue tracking build ID must not be empty")
70+
}
71+
if s.NotificationID == "" {
72+
return fmt.Errorf("issue tracking notification ID must not be empty")
73+
}
74+
if s.AfterTaskID == "" && s.AfterUnitID != "" {
75+
return fmt.Errorf("issue tracking cursor unit requires a task")
76+
}
77+
if s.Version <= 0 {
78+
return fmt.Errorf("issue tracking version must be positive")
79+
}
80+
switch s.Phase {
81+
case IssueTrackingPhaseRefreshing, IssueTrackingPhaseRefreshed, IssueTrackingPhaseFinalizing, IssueTrackingPhaseFinalized:
82+
return nil
83+
default:
84+
return fmt.Errorf("invalid issue tracking phase %q", s.Phase)
85+
}
86+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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 entity
16+
17+
import (
18+
"testing"
19+
20+
"github.com/stretchr/testify/require"
21+
)
22+
23+
func TestIssueTrackingState(t *testing.T) {
24+
for _, phase := range []IssueTrackingPhase{IssueTrackingPhaseRefreshing, IssueTrackingPhaseRefreshed, IssueTrackingPhaseFinalizing, IssueTrackingPhaseFinalized} {
25+
t.Run(string(phase), func(t *testing.T) {
26+
state := IssueTrackingState{RequestID: "10", BuildID: "build/10", NotificationID: "notification/10", Phase: phase, Version: 1}
27+
require.NoError(t, state.Validate())
28+
require.Equal(t, phase == IssueTrackingPhaseRefreshing || phase == IssueTrackingPhaseFinalizing, state.IsPending())
29+
})
30+
}
31+
for _, tt := range []struct {
32+
name string
33+
change func(*IssueTrackingState)
34+
}{
35+
{"missing request", func(s *IssueTrackingState) { s.RequestID = "" }},
36+
{"noncanonical request", func(s *IssueTrackingState) { s.RequestID = "010" }},
37+
{"nonnumeric request", func(s *IssueTrackingState) { s.RequestID = "request/10" }},
38+
{"missing notification", func(s *IssueTrackingState) { s.NotificationID = "" }},
39+
{"unit cursor without task", func(s *IssueTrackingState) { s.AfterUnitID = "unit" }},
40+
{"missing build", func(s *IssueTrackingState) { s.BuildID = "" }},
41+
{"unknown phase", func(s *IssueTrackingState) { s.Phase = IssueTrackingPhaseUnknown }},
42+
{"unsupported phase", func(s *IssueTrackingState) { s.Phase = "other" }},
43+
{"zero version", func(s *IssueTrackingState) { s.Version = 0 }},
44+
} {
45+
t.Run(tt.name, func(t *testing.T) {
46+
state := IssueTrackingState{RequestID: "10", BuildID: "build/10", NotificationID: "notification/10", Phase: IssueTrackingPhaseRefreshing, Version: 1}
47+
tt.change(&state)
48+
require.Error(t, state.Validate())
49+
})
50+
}
51+
}

‎stovepipe/extension/storage/BUILD.bazel‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ go_library(
66
"build_issue_store.go",
77
"build_store.go",
88
"issue_log_store.go",
9+
"issue_tracking_store.go",
910
"open_issue_store.go",
1011
"project_fact_by_degree_store.go",
1112
"queue_log_store.go",

‎stovepipe/extension/storage/README.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Storage
22

3-
Pluggable persistence interfaces for Stovepipe entities (`RequestStore`, `RequestURIStore`, `QueueStore`, `BuildStore`, `BuildIssueStore`, `OpenIssueStore`, `IssueLogStore`). Implementations live under `extension/storage/<impl>/`.
3+
Pluggable persistence interfaces for Stovepipe entities (`RequestStore`, `RequestURIStore`, `QueueStore`, `BuildStore`, `BuildIssueStore`, `OpenIssueStore`, `IssueLogStore`, `IssueTrackingStore`). Implementations live under `extension/storage/<impl>/`.
44

55
The aggregate is resolved per queue through a factory keyed by queue name, mirroring the extension contract and the [submitqueue storage seam](../../../submitqueue/extension/storage/README.md#queue-scoped-resolution): a resolved instance is bound to its queue, entity arguments whose queue disagrees with the binding are rejected, and reads never surface another queue's records. Stovepipe has no cross-queue read paths, so every store — including `QueueStore`, whose row key is the queue name itself — lives inside the queue-scoped aggregate; there is no global remainder.
66

@@ -63,3 +63,11 @@ The build ID identifies the build where the issue was first seen or confirmed re
6363
The MySQL key uses the same binary identifier limits as the issue stores, plus a 32-byte event field, for a maximum of 2586 bytes. Reads and creates use primary-key operations; there is no update or deletion API, metadata collection, or repair state.
6464

6565
History persistence does not alter open-issue membership or validate transitions against it. No runtime writer is enabled here. A later reconciliation controller must own opening/resolution decisions, retry-safe writes to both stores, and protection against stale build analysis. History enumeration is outside this storage contract.
66+
67+
## Issue reconciliation progress
68+
69+
`IssueTrackingStore` retains one queue-scoped operational `IssueTrackingState`, independently of open-issue membership and user-facing summaries. It records the source request and build, whether refresh or final writes are pending or complete, the owning notification, an exclusive task/unit cursor, and its optimistic version. The zero cursor starts a pass. A cursor checkpoint follows the writes for every processed key; notification identity lets the controller distinguish its own continuation from helping another pass. Keeping it after the last open row is removed preserves the source-order boundary. The MySQL table is keyed only by queue; creates, reads, and updates use complete primary keys with no secondary indexes or cross-table transaction.
70+
71+
The store does not decide source ordering, apply issue transitions, or advance versions. Its caller supplies the guard version and replacement version. A later controller will own acceptance, resumption of pending writes, and completion; no runtime writer is enabled here.
72+
73+
The issue-tracking table must include the notification and cursor columns before updated readers or writers are deployed. No runtime writer is enabled by the storage layer. Notification IDs allow up to 1020 bytes, matching the queue message ID byte capacity; cursor limits match the issue stores.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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 storage
16+
17+
//go:generate mockgen -source=issue_tracking_store.go -destination=mock/issue_tracking_store_mock.go -package=mock
18+
19+
import (
20+
"context"
21+
22+
"github.com/uber/submitqueue/stovepipe/entity"
23+
)
24+
25+
// IssueTrackingStore retains one operational reconciliation state for the bound queue.
26+
// It remains present when no issues are open so older results cannot reopen closed issues.
27+
type IssueTrackingStore interface {
28+
// Get returns ErrNotFound before the queue's first reconciliation is accepted.
29+
Get(context.Context) (entity.IssueTrackingState, error)
30+
// Create returns ErrAlreadyExists when the queue already has a state.
31+
Create(context.Context, entity.IssueTrackingState) error
32+
// Update replaces the state only at oldVersion; callers supply newVersion without store arithmetic.
33+
Update(ctx context.Context, state entity.IssueTrackingState, oldVersion, newVersion int32) error
34+
}

‎stovepipe/extension/storage/mock/BUILD.bazel‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ go_library(
66
"build_issue_store_mock.go",
77
"build_store_mock.go",
88
"issue_log_store_mock.go",
9+
"issue_tracking_store_mock.go",
910
"open_issue_store_mock.go",
1011
"project_fact_by_degree_store_mock.go",
1112
"queue_log_store_mock.go",

‎stovepipe/extension/storage/mock/issue_tracking_store_mock.go‎

Lines changed: 85 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎stovepipe/extension/storage/mock/storage_mock.go‎

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎stovepipe/extension/storage/mysql/BUILD.bazel‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ go_library(
66
"build_issue_store.go",
77
"build_store.go",
88
"issue_log_store.go",
9+
"issue_tracking_store.go",
910
"open_issue_store.go",
1011
"project_fact_by_degree_store.go",
1112
"queue_log_store.go",
@@ -38,6 +39,7 @@ go_test(
3839
"build_issue_update_test.go",
3940
"build_store_test.go",
4041
"issue_log_store_test.go",
42+
"issue_tracking_store_test.go",
4143
"open_issue_list_test.go",
4244
"open_issue_store_test.go",
4345
"project_fact_by_degree_store_test.go",

0 commit comments

Comments
 (0)