Skip to content

Commit 76978d4

Browse files
committed
feat(stovepipe): Store currently open build issues
1 parent 62c8e8a commit 76978d4

18 files changed

Lines changed: 829 additions & 1 deletion

‎stovepipe/entity/BUILD.bazel‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ go_library(
77
"build_issue.go",
88
"ingest.go",
99
"list.go",
10+
"open_build_issue.go",
1011
"project_status.go",
1112
"queue.go",
1213
"queue_config.go",
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
// OpenBuildIssue is a currently open validation issue within a queue.
18+
// Its task and unit identify the condition; the first-observed build identifies
19+
// this opening and is immutable until removal.
20+
type OpenBuildIssue struct {
21+
// TaskID identifies the logical validation task.
22+
TaskID string `json:"task_id"`
23+
// UnitID identifies the validation unit; empty for a task-wide issue.
24+
UnitID string `json:"unit_id"`
25+
// FirstObservedBuildID identifies the build that opened this occurrence.
26+
FirstObservedBuildID string `json:"first_observed_build_id"`
27+
// LastObservedBuildID identifies the build of the latest persisted observation.
28+
LastObservedBuildID string `json:"last_observed_build_id"`
29+
// Version is the positive optimistic-lock version of this opening.
30+
Version int32 `json:"version"`
31+
}

‎stovepipe/extension/storage/BUILD.bazel‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go_library(
55
srcs = [
66
"build_issue_store.go",
77
"build_store.go",
8+
"open_build_issue_store.go",
89
"queue_log_store.go",
910
"queue_store.go",
1011
"queue_summary_store.go",

‎stovepipe/extension/storage/README.md‎

Lines changed: 13 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`). Implementations live under `extension/storage/<impl>/`.
3+
Pluggable persistence interfaces for Stovepipe entities (`RequestStore`, `RequestURIStore`, `QueueStore`, `BuildStore`, `BuildIssueStore`, `OpenBuildIssueStore`). 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

@@ -31,3 +31,15 @@ Same design space as [`submitqueue/extension/storage`](../../../submitqueue/exte
3131
Each observation is stored separately, not in a serialized collection. Observations are historical evidence; they do not track whether an issue is currently open or resolved. No update, deletion, or absence-based recovery is implied by this store.
3232

3333
The MySQL primary key contains the queue, build ID, task ID, and unit ID directly as binary strings, preserving byte-exact identity. Their limits are 255, 1020, 255, and 1024 bytes respectively, for a combined maximum of 2554 bytes. Creates and reads reject identifiers exceeding these limits before querying the database; identifiers are never truncated.
34+
35+
## Currently open issues
36+
37+
`OpenBuildIssueStore` contains only currently open issues, keyed by task and unit within the bound queue. It is separate from immutable per-build observations: removing an open row never removes those observations. There is no open/resolved flag; a resolved issue has no row here.
38+
39+
The first-observed build is fixed for one continuous opening. A repeated observation updates the last-observed build and version, preserving the first-observed build. Reopening after removal uses a different first-observed build ID. This supplies context for a future issue-history log without adding a second condition key or storing history in the current row.
40+
41+
Updates and deletion check both the first-observed build and the caller's expected version. The build check protects against an old delivery mutating a reopened issue whose version has restarted. A missing row, changed opening, or version conflict returns `ErrVersionMismatch`; callers own version arithmetic and retry decisions. Successful deletion is not independently idempotent: a caller must reload after an ambiguous failure, not delete a newer opening.
42+
43+
MySQL identifiers use the same binary byte limits as per-build observations. Build IDs are opaque context, not ordering tokens. Optimistic locking protects row changes but does not decide which build is newer. The store does not choose resolution, enumerate issues, write lifecycle history, or reconcile analysis responses.
44+
45+
Resolution belongs to a later controller: a successful, authoritative final analysis can establish that a previously failing condition is absent. Build terminality alone is not evidence that analysis succeeded; an analysis failure or an unfinished poll must not close issues. The reference host defaults to one admitted validation per queue, but final analysis can lag behind a later build because polling and analysis run independently. Source-order reconciliation is separate from these storage primitives.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go_library(
55
srcs = [
66
"build_issue_store_mock.go",
77
"build_store_mock.go",
8+
"open_build_issue_store_mock.go",
89
"queue_log_store_mock.go",
910
"queue_store_mock.go",
1011
"queue_summary_store_mock.go",

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

Lines changed: 99 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
@@ -5,6 +5,7 @@ go_library(
55
srcs = [
66
"build_issue_store.go",
77
"build_store.go",
8+
"open_build_issue_store.go",
89
"queue_log_store.go",
910
"queue_store.go",
1011
"queue_summary_store.go",
@@ -32,6 +33,7 @@ go_test(
3233
srcs = [
3334
"build_issue_store_test.go",
3435
"build_store_test.go",
36+
"open_build_issue_store_test.go",
3537
"queue_store_test.go",
3638
"queue_summary_store_test.go",
3739
"request_acceptance_store_test.go",
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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 mysql
16+
17+
import (
18+
"context"
19+
"database/sql"
20+
"errors"
21+
"fmt"
22+
23+
"github.com/uber-go/tally"
24+
"github.com/uber/submitqueue/platform/metrics"
25+
"github.com/uber/submitqueue/stovepipe/entity"
26+
"github.com/uber/submitqueue/stovepipe/extension/storage"
27+
)
28+
29+
type openBuildIssueStore struct {
30+
db *sql.DB
31+
scope tally.Scope
32+
queue string
33+
}
34+
35+
// NewOpenBuildIssueStore binds currently open issues to queue over the shared database.
36+
func NewOpenBuildIssueStore(db *sql.DB, scope tally.Scope, queue string) storage.OpenBuildIssueStore {
37+
return &openBuildIssueStore{db: db, scope: scope, queue: queue}
38+
}
39+
40+
func (s *openBuildIssueStore) Create(ctx context.Context, issue entity.OpenBuildIssue) (retErr error) {
41+
op := metrics.Begin(s.scope, "create", metrics.StorageLatencyBuckets)
42+
defer func() { op.Complete(retErr) }()
43+
44+
if err := s.validateIssue(issue, true); err != nil {
45+
return err
46+
}
47+
if issue.Version <= 0 {
48+
return fmt.Errorf("open build issue version must be positive")
49+
}
50+
51+
_, err := s.db.ExecContext(ctx,
52+
`INSERT INTO open_build_issue (queue, task_id, unit_id, first_observed_build_id, last_observed_build_id, version)
53+
VALUES (?, ?, ?, ?, ?, ?)`,
54+
s.queue, issue.TaskID, issue.UnitID, issue.FirstObservedBuildID, issue.LastObservedBuildID, issue.Version)
55+
if err != nil {
56+
if isDuplicateEntry(err) {
57+
return fmt.Errorf("open build issue task=%q unit=%q: %w", issue.TaskID, issue.UnitID, storage.ErrAlreadyExists)
58+
}
59+
60+
return fmt.Errorf("failed to create open build issue: %w", err)
61+
}
62+
63+
return nil
64+
}
65+
66+
func (s *openBuildIssueStore) Get(ctx context.Context, taskID, unitID string) (ret entity.OpenBuildIssue, retErr error) {
67+
op := metrics.Begin(s.scope, "get", metrics.StorageLatencyBuckets)
68+
defer func() { op.Complete(retErr) }()
69+
70+
if err := validateBuildIssueKey(s.queue, "", taskID, unitID); err != nil {
71+
return entity.OpenBuildIssue{}, err
72+
}
73+
74+
var issue entity.OpenBuildIssue
75+
err := s.db.QueryRowContext(ctx,
76+
`SELECT task_id, unit_id, first_observed_build_id, last_observed_build_id, version
77+
FROM open_build_issue WHERE queue = ? AND task_id = ? AND unit_id = ?`,
78+
s.queue, taskID, unitID,
79+
).Scan(&issue.TaskID, &issue.UnitID, &issue.FirstObservedBuildID, &issue.LastObservedBuildID, &issue.Version)
80+
if errors.Is(err, sql.ErrNoRows) {
81+
return entity.OpenBuildIssue{}, storage.WrapNotFound(err)
82+
}
83+
if err != nil {
84+
return entity.OpenBuildIssue{}, fmt.Errorf("failed to get open build issue: %w", err)
85+
}
86+
87+
return issue, nil
88+
}
89+
90+
func (s *openBuildIssueStore) Update(ctx context.Context, issue entity.OpenBuildIssue, oldVersion, newVersion int32) (retErr error) {
91+
op := metrics.Begin(s.scope, "update", metrics.StorageLatencyBuckets)
92+
defer func() { op.Complete(retErr) }()
93+
94+
if err := s.validateIssue(issue, true); err != nil {
95+
return err
96+
}
97+
if oldVersion <= 0 || newVersion <= 0 {
98+
return fmt.Errorf("open build issue versions must be positive")
99+
}
100+
101+
result, err := s.db.ExecContext(ctx,
102+
`UPDATE open_build_issue SET last_observed_build_id = ?, version = ?
103+
WHERE queue = ? AND task_id = ? AND unit_id = ? AND first_observed_build_id = ? AND version = ?`,
104+
issue.LastObservedBuildID, newVersion, s.queue, issue.TaskID, issue.UnitID, issue.FirstObservedBuildID, oldVersion)
105+
if err != nil {
106+
return fmt.Errorf("failed to update open build issue: %w", err)
107+
}
108+
109+
return checkOpenBuildIssueMutation(result)
110+
}
111+
112+
func (s *openBuildIssueStore) Delete(ctx context.Context, issue entity.OpenBuildIssue, oldVersion int32) (retErr error) {
113+
op := metrics.Begin(s.scope, "delete", metrics.StorageLatencyBuckets)
114+
defer func() { op.Complete(retErr) }()
115+
116+
if err := s.validateIssue(issue, false); err != nil {
117+
return err
118+
}
119+
if oldVersion <= 0 {
120+
return fmt.Errorf("open build issue version must be positive")
121+
}
122+
123+
result, err := s.db.ExecContext(ctx,
124+
`DELETE FROM open_build_issue
125+
WHERE queue = ? AND task_id = ? AND unit_id = ? AND first_observed_build_id = ? AND version = ?`,
126+
s.queue, issue.TaskID, issue.UnitID, issue.FirstObservedBuildID, oldVersion)
127+
if err != nil {
128+
return fmt.Errorf("failed to delete open build issue: %w", err)
129+
}
130+
131+
return checkOpenBuildIssueMutation(result)
132+
}
133+
134+
func (s *openBuildIssueStore) validateIssue(issue entity.OpenBuildIssue, requireLastBuild bool) error {
135+
if err := validateBuildIssueKey(s.queue, issue.FirstObservedBuildID, issue.TaskID, issue.UnitID); err != nil {
136+
return err
137+
}
138+
if issue.FirstObservedBuildID == "" {
139+
return fmt.Errorf("open build issue first-observed build ID must not be empty")
140+
}
141+
if requireLastBuild {
142+
if err := validateBuildIssueKey(s.queue, issue.LastObservedBuildID, issue.TaskID, issue.UnitID); err != nil {
143+
return err
144+
}
145+
if issue.LastObservedBuildID == "" {
146+
return fmt.Errorf("open build issue last-observed build ID must not be empty")
147+
}
148+
}
149+
150+
return nil
151+
}
152+
153+
func checkOpenBuildIssueMutation(result sql.Result) error {
154+
n, err := result.RowsAffected()
155+
if err != nil {
156+
return fmt.Errorf("failed to get open build issue rows affected: %w", err)
157+
}
158+
if n != 1 {
159+
return storage.ErrVersionMismatch
160+
}
161+
162+
return nil
163+
}

0 commit comments

Comments
 (0)