Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions stovepipe/entity/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ go_library(
"build.go",
"build_issue.go",
"ingest.go",
"issue_log.go",
"list.go",
"open_issue.go",
"project_fact_by_degree.go",
Expand Down Expand Up @@ -33,6 +34,7 @@ go_test(
name = "go_default_test",
srcs = [
"build_test.go",
"issue_log_test.go",
"project_fact_by_degree_test.go",
"queue_state_test.go",
"request_id_test.go",
Expand Down
60 changes: 60 additions & 0 deletions stovepipe/entity/issue_log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package entity

import "fmt"

// IssueEvent identifies an issue lifecycle transition.
type IssueEvent string

const (
// IssueEventUnknown is the unset event value.
IssueEventUnknown IssueEvent = ""
// IssueEventOpened records that an issue became open.
IssueEventOpened IssueEvent = "opened"
// IssueEventResolved records that an issue was resolved.
IssueEventResolved IssueEvent = "resolved"
)

// IssueLog is an immutable issue lifecycle transition within a queue.
// Task, unit, build, and event identify one transition.
type IssueLog struct {
// TaskID identifies the logical validation task.
TaskID string `json:"task_id"`
// UnitID identifies the validation unit; empty for a task-wide issue.
UnitID string `json:"unit_id"`
// Event identifies the opening or resolution.
Event IssueEvent `json:"event"`
// BuildID identifies the build where the issue was first seen or confirmed resolved.
BuildID string `json:"build_id"`
// TimestampMs is when the transition was first recorded, in Unix milliseconds.
TimestampMs int64 `json:"timestamp_ms"`
}

// Validate verifies the invariants required for a newly persisted issue log.
func (l IssueLog) Validate() error {
if l.BuildID == "" {
return fmt.Errorf("issue log build ID must not be empty")
}
if l.TimestampMs <= 0 {
return fmt.Errorf("issue log timestamp must be positive")
}
switch l.Event {
case IssueEventOpened, IssueEventResolved:
return nil
default:
return fmt.Errorf("invalid issue event %q", l.Event)
}
}
52 changes: 52 additions & 0 deletions stovepipe/entity/issue_log_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package entity

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestIssueLogValidate(t *testing.T) {
for _, tt := range []struct {
name string
change func(*IssueLog)
invalid bool
}{
{name: "opened"},
{name: "resolved", change: func(l *IssueLog) { l.Event = IssueEventResolved }},
{name: "task-wide issue", change: func(l *IssueLog) { l.UnitID = "" }},
{name: "missing build", change: func(l *IssueLog) { l.BuildID = "" }, invalid: true},
{name: "zero timestamp", change: func(l *IssueLog) { l.TimestampMs = 0 }, invalid: true},
{name: "negative timestamp", change: func(l *IssueLog) { l.TimestampMs = -1 }, invalid: true},
{name: "unknown event", change: func(l *IssueLog) { l.Event = IssueEventUnknown }, invalid: true},
{name: "unsupported event", change: func(l *IssueLog) { l.Event = "other" }, invalid: true},
} {
t.Run(tt.name, func(t *testing.T) {
log := IssueLog{TaskID: "task", UnitID: "unit",
Event: IssueEventOpened, BuildID: "build/1", TimestampMs: 1000}
if tt.change != nil {
tt.change(&log)
}

if tt.invalid {
require.Error(t, log.Validate())
} else {
require.NoError(t, log.Validate())
}
})
}
}
1 change: 1 addition & 0 deletions stovepipe/extension/storage/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go_library(
srcs = [
"build_issue_store.go",
"build_store.go",
"issue_log_store.go",
"open_issue_store.go",
"project_fact_by_degree_store.go",
"queue_log_store.go",
Expand Down
14 changes: 12 additions & 2 deletions stovepipe/extension/storage/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Storage

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

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.

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

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.

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

## Issue lifecycle history

`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.

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.

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.

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.
34 changes: 34 additions & 0 deletions stovepipe/extension/storage/issue_log_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package storage

//go:generate mockgen -source=issue_log_store.go -destination=mock/issue_log_store_mock.go -package=mock

import (
"context"

"github.com/uber/submitqueue/stovepipe/entity"
)

// IssueLogStore retains immutable issue transitions within the bound queue.
// Task, unit, build, and event identify one transition.
type IssueLogStore interface {
// Create returns ErrAlreadyExists for a previously recorded transition.
// A duplicate never replaces the original timestamp.
Create(ctx context.Context, log entity.IssueLog) error

// Get returns ErrNotFound when the transition has not been recorded.
Get(ctx context.Context, taskID, unitID, buildID string, event entity.IssueEvent) (entity.IssueLog, error)
}
1 change: 1 addition & 0 deletions stovepipe/extension/storage/mock/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go_library(
srcs = [
"build_issue_store_mock.go",
"build_store_mock.go",
"issue_log_store_mock.go",
"open_issue_store_mock.go",
"project_fact_by_degree_store_mock.go",
"queue_log_store_mock.go",
Expand Down
71 changes: 71 additions & 0 deletions stovepipe/extension/storage/mock/issue_log_store_mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions stovepipe/extension/storage/mock/storage_mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions stovepipe/extension/storage/mysql/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go_library(
srcs = [
"build_issue_store.go",
"build_store.go",
"issue_log_store.go",
"open_issue_store.go",
"project_fact_by_degree_store.go",
"queue_log_store.go",
Expand Down Expand Up @@ -34,6 +35,7 @@ go_test(
srcs = [
"build_issue_store_test.go",
"build_store_test.go",
"issue_log_store_test.go",
"open_issue_store_test.go",
"project_fact_by_degree_store_test.go",
"queue_store_test.go",
Expand Down
Loading
Loading