Skip to content

Commit db03163

Browse files
authored
feat(stovepipe): Add issue lifecycle history (#923)
## Summary **What**: - Persist immutable opening and resolution history, retaining the associated build and timestamp after an issue resolves or reopens. **Why**: - Preserve issue lifecycle history separately from current open issues and per-build observations, without enabling reconciliation or repair tracking yet. ## Test Plan - [x] Add unit tests. - [x] Verify history retention, duplicate replay, concurrent creation, and identifier boundaries 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. #923 1. #924 1. #925
1 parent 0d00181 commit db03163

19 files changed

Lines changed: 748 additions & 2 deletions

‎stovepipe/entity/BUILD.bazel‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ go_library(
66
"build.go",
77
"build_issue.go",
88
"ingest.go",
9+
"issue_log.go",
910
"list.go",
1011
"open_issue.go",
1112
"project_fact_by_degree.go",
@@ -33,6 +34,7 @@ go_test(
3334
name = "go_default_test",
3435
srcs = [
3536
"build_test.go",
37+
"issue_log_test.go",
3638
"project_fact_by_degree_test.go",
3739
"queue_state_test.go",
3840
"request_id_test.go",

‎stovepipe/entity/issue_log.go‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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 "fmt"
18+
19+
// IssueEvent identifies an issue lifecycle transition.
20+
type IssueEvent string
21+
22+
const (
23+
// IssueEventUnknown is the unset event value.
24+
IssueEventUnknown IssueEvent = ""
25+
// IssueEventOpened records that an issue became open.
26+
IssueEventOpened IssueEvent = "opened"
27+
// IssueEventResolved records that an issue was resolved.
28+
IssueEventResolved IssueEvent = "resolved"
29+
)
30+
31+
// IssueLog is an immutable issue lifecycle transition within a queue.
32+
// Task, unit, build, and event identify one transition.
33+
type IssueLog struct {
34+
// TaskID identifies the logical validation task.
35+
TaskID string `json:"task_id"`
36+
// UnitID identifies the validation unit; empty for a task-wide issue.
37+
UnitID string `json:"unit_id"`
38+
// Event identifies the opening or resolution.
39+
Event IssueEvent `json:"event"`
40+
// BuildID identifies the build where the issue was first seen or confirmed resolved.
41+
BuildID string `json:"build_id"`
42+
// TimestampMs is when the transition was first recorded, in Unix milliseconds.
43+
TimestampMs int64 `json:"timestamp_ms"`
44+
}
45+
46+
// Validate verifies the invariants required for a newly persisted issue log.
47+
func (l IssueLog) Validate() error {
48+
if l.BuildID == "" {
49+
return fmt.Errorf("issue log build ID must not be empty")
50+
}
51+
if l.TimestampMs <= 0 {
52+
return fmt.Errorf("issue log timestamp must be positive")
53+
}
54+
switch l.Event {
55+
case IssueEventOpened, IssueEventResolved:
56+
return nil
57+
default:
58+
return fmt.Errorf("invalid issue event %q", l.Event)
59+
}
60+
}

‎stovepipe/entity/issue_log_test.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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 TestIssueLogValidate(t *testing.T) {
24+
for _, tt := range []struct {
25+
name string
26+
change func(*IssueLog)
27+
invalid bool
28+
}{
29+
{name: "opened"},
30+
{name: "resolved", change: func(l *IssueLog) { l.Event = IssueEventResolved }},
31+
{name: "task-wide issue", change: func(l *IssueLog) { l.UnitID = "" }},
32+
{name: "missing build", change: func(l *IssueLog) { l.BuildID = "" }, invalid: true},
33+
{name: "zero timestamp", change: func(l *IssueLog) { l.TimestampMs = 0 }, invalid: true},
34+
{name: "negative timestamp", change: func(l *IssueLog) { l.TimestampMs = -1 }, invalid: true},
35+
{name: "unknown event", change: func(l *IssueLog) { l.Event = IssueEventUnknown }, invalid: true},
36+
{name: "unsupported event", change: func(l *IssueLog) { l.Event = "other" }, invalid: true},
37+
} {
38+
t.Run(tt.name, func(t *testing.T) {
39+
log := IssueLog{TaskID: "task", UnitID: "unit",
40+
Event: IssueEventOpened, BuildID: "build/1", TimestampMs: 1000}
41+
if tt.change != nil {
42+
tt.change(&log)
43+
}
44+
45+
if tt.invalid {
46+
require.Error(t, log.Validate())
47+
} else {
48+
require.NoError(t, log.Validate())
49+
}
50+
})
51+
}
52+
}

‎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+
"issue_log_store.go",
89
"open_issue_store.go",
910
"project_fact_by_degree_store.go",
1011
"queue_log_store.go",

‎stovepipe/extension/storage/README.md‎

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

@@ -42,4 +42,14 @@ An open row is immutable. Its first-seen build identifies the current opening, n
4242

4343
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.
4444

45-
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.
45+
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. Lifecycle occurrences are retained separately by `IssueLogStore`. Source-order reconciliation belongs to later controllers, not this membership store.
46+
47+
## Issue lifecycle history
48+
49+
`IssueLogStore` retains immutable opened and resolved transitions independently of current open-issue membership and per-build observations. Each transition is keyed by task, unit, build, and event within the bound queue. An empty unit identifies a task-wide issue. History remains after an open row is removed or recreated; transitions do not carry a separate reference to the opening they belong to.
50+
51+
The build ID identifies the build where the issue was first seen or confirmed resolved. The application supplies a positive Unix-millisecond timestamp for when the transition was first recorded, not when the build finished. A duplicate identity returns `ErrAlreadyExists` without replacing the original timestamp. Callers can treat replay as success or read the original event. Build IDs are opaque and do not establish transition ordering.
52+
53+
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.
54+
55+
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.
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_log_store.go -destination=mock/issue_log_store_mock.go -package=mock
18+
19+
import (
20+
"context"
21+
22+
"github.com/uber/submitqueue/stovepipe/entity"
23+
)
24+
25+
// IssueLogStore retains immutable issue transitions within the bound queue.
26+
// Task, unit, build, and event identify one transition.
27+
type IssueLogStore interface {
28+
// Create returns ErrAlreadyExists for a previously recorded transition.
29+
// A duplicate never replaces the original timestamp.
30+
Create(ctx context.Context, log entity.IssueLog) error
31+
32+
// Get returns ErrNotFound when the transition has not been recorded.
33+
Get(ctx context.Context, taskID, unitID, buildID string, event entity.IssueEvent) (entity.IssueLog, error)
34+
}

‎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+
"issue_log_store_mock.go",
89
"open_issue_store_mock.go",
910
"project_fact_by_degree_store_mock.go",
1011
"queue_log_store_mock.go",

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

Lines changed: 71 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+
"issue_log_store.go",
89
"open_issue_store.go",
910
"project_fact_by_degree_store.go",
1011
"queue_log_store.go",
@@ -34,6 +35,7 @@ go_test(
3435
srcs = [
3536
"build_issue_store_test.go",
3637
"build_store_test.go",
38+
"issue_log_store_test.go",
3739
"open_issue_store_test.go",
3840
"project_fact_by_degree_store_test.go",
3941
"queue_store_test.go",

0 commit comments

Comments
 (0)