Skip to content

Commit 473ab2c

Browse files
authored
feat(stovepipe): Store currently open issues (#916)
## Summary **What**: - Add queue-scoped storage for currently open issues, retaining the build that opened each occurrence. - Make deletion safe to repeat without removing a later opening of the same issue. **Why**: - Separate current issue state from immutable per-build observations and prepare for issue lifecycle history without enabling reconciliation yet. ## Test Plan - [x] Add unit tests. - [x] Verify open, remove, reopen, and concurrent creation against MySQL. ## Revert Plan - Revert this PR. No runtime writer is enabled; the additive table can remain unused without affecting validation. ## Issue Links - [CODEM-607](https://linear.app/uber/issue/CODEM-607/build-analysis) ## Stack 1. #902 1. #907 1. #915 1. #909 1. #916
1 parent 0ef1667 commit 473ab2c

18 files changed

Lines changed: 694 additions & 1 deletion

File tree

‎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_issue.go",
1011
"project_status.go",
1112
"queue.go",
1213
"queue_config.go",

‎stovepipe/entity/open_issue.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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+
// OpenIssue is a currently open validation issue within a queue.
18+
// Its task and unit identify the condition; the first-seen build identifies
19+
// this opening and is immutable until removal.
20+
type OpenIssue 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+
// FirstSeenBuildID identifies the build where this opening of the issue was first observed.
26+
FirstSeenBuildID string `json:"first_seen_build_id"`
27+
}

‎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_issue_store.go",
89
"queue_log_store.go",
910
"queue_store.go",
1011
"queue_summary_store.go",

‎stovepipe/extension/storage/README.md‎

Lines changed: 11 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`, `OpenIssueStore`). 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,13 @@ 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+
`OpenIssueStore` contains only currently open issues, keyed by task and unit within the bound queue. Removing an open row never removes immutable per-build observations. There is no open/resolved flag; a resolved issue has no row here.
38+
39+
An open row is immutable. Its first-seen build identifies the current opening, not its latest observation. Repeated observations remain in the per-build store. Reopening after removal uses a different first-seen build ID; no last-observed build, version counter, or update operation is stored here.
40+
41+
Deletion matches the task, unit, and first-seen build within the bound queue. It is safe to repeat: an absent opening is a no-op, and a stale deletion never removes a different opening of the same condition. Database failures are returned for retry.
42+
43+
MySQL identifiers use the same binary byte limits as per-build observations. Build IDs are opaque context, not ordering tokens. The store does not choose resolution, enumerate issues, write lifecycle history, or reconcile analysis responses. History and source-order reconciliation belong to later controllers, not this membership store.

‎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_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_issue_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
@@ -5,6 +5,7 @@ go_library(
55
srcs = [
66
"build_issue_store.go",
77
"build_store.go",
8+
"open_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_issue_store_test.go",
3537
"queue_store_test.go",
3638
"queue_summary_store_test.go",
3739
"request_acceptance_store_test.go",
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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 openIssueStore struct {
30+
db *sql.DB
31+
scope tally.Scope
32+
queue string
33+
}
34+
35+
// NewOpenIssueStore binds currently open issues to queue over the shared database.
36+
func NewOpenIssueStore(db *sql.DB, scope tally.Scope, queue string) storage.OpenIssueStore {
37+
return &openIssueStore{db: db, scope: scope, queue: queue}
38+
}
39+
40+
func (s *openIssueStore) Create(ctx context.Context, issue entity.OpenIssue) (retErr error) {
41+
op := metrics.Begin(s.scope, "create", metrics.StorageLatencyBuckets)
42+
defer func() { op.Complete(retErr) }()
43+
44+
if err := s.validateIssue(issue); err != nil {
45+
return err
46+
}
47+
48+
_, err := s.db.ExecContext(ctx,
49+
`INSERT INTO open_issue (queue, task_id, unit_id, first_seen_build_id)
50+
VALUES (?, ?, ?, ?)`,
51+
s.queue, issue.TaskID, issue.UnitID, issue.FirstSeenBuildID)
52+
if err != nil {
53+
if isDuplicateEntry(err) {
54+
return fmt.Errorf("open issue task=%q unit=%q: %w", issue.TaskID, issue.UnitID, storage.ErrAlreadyExists)
55+
}
56+
57+
return fmt.Errorf("failed to create open issue: %w", err)
58+
}
59+
60+
return nil
61+
}
62+
63+
func (s *openIssueStore) Get(ctx context.Context, taskID, unitID string) (ret entity.OpenIssue, retErr error) {
64+
op := metrics.Begin(s.scope, "get", metrics.StorageLatencyBuckets)
65+
defer func() { op.Complete(retErr) }()
66+
67+
if err := validateBuildIssueKey(s.queue, "", taskID, unitID); err != nil {
68+
return entity.OpenIssue{}, err
69+
}
70+
71+
var issue entity.OpenIssue
72+
err := s.db.QueryRowContext(ctx,
73+
`SELECT task_id, unit_id, first_seen_build_id
74+
FROM open_issue WHERE queue = ? AND task_id = ? AND unit_id = ?`,
75+
s.queue, taskID, unitID,
76+
).Scan(&issue.TaskID, &issue.UnitID, &issue.FirstSeenBuildID)
77+
if errors.Is(err, sql.ErrNoRows) {
78+
return entity.OpenIssue{}, storage.WrapNotFound(err)
79+
}
80+
if err != nil {
81+
return entity.OpenIssue{}, fmt.Errorf("failed to get open issue: %w", err)
82+
}
83+
84+
return issue, nil
85+
}
86+
87+
func (s *openIssueStore) Delete(ctx context.Context, issue entity.OpenIssue) (retErr error) {
88+
op := metrics.Begin(s.scope, "delete", metrics.StorageLatencyBuckets)
89+
defer func() { op.Complete(retErr) }()
90+
91+
if err := s.validateIssue(issue); err != nil {
92+
return err
93+
}
94+
95+
_, err := s.db.ExecContext(ctx,
96+
`DELETE FROM open_issue
97+
WHERE queue = ? AND task_id = ? AND unit_id = ? AND first_seen_build_id = ?`,
98+
s.queue, issue.TaskID, issue.UnitID, issue.FirstSeenBuildID)
99+
if err != nil {
100+
return fmt.Errorf("failed to delete open issue: %w", err)
101+
}
102+
103+
return nil
104+
}
105+
106+
func (s *openIssueStore) validateIssue(issue entity.OpenIssue) error {
107+
if err := validateBuildIssueKey(s.queue, issue.FirstSeenBuildID, issue.TaskID, issue.UnitID); err != nil {
108+
return err
109+
}
110+
if issue.FirstSeenBuildID == "" {
111+
return fmt.Errorf("open issue first-seen build ID must not be empty")
112+
}
113+
return nil
114+
}

0 commit comments

Comments
 (0)