Skip to content
Open
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 @@ -7,6 +7,7 @@ go_library(
"build_issue.go",
"ingest.go",
"issue_log.go",
"issue_tracking_state.go",
"list.go",
"open_issue.go",
"project_fact_by_degree.go",
Expand Down Expand Up @@ -35,6 +36,7 @@ go_test(
srcs = [
"build_test.go",
"issue_log_test.go",
"issue_tracking_state_test.go",
"project_fact_by_degree_test.go",
"queue_state_test.go",
"request_id_test.go",
Expand Down
73 changes: 73 additions & 0 deletions stovepipe/entity/issue_tracking_state.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// 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"

"github.com/uber/submitqueue/platform/base/id"
)

// IssueTrackingPhase describes whether one source build's issue updates are pending or complete.
type IssueTrackingPhase string

const (
// IssueTrackingPhaseUnknown is the unset phase.
IssueTrackingPhaseUnknown IssueTrackingPhase = ""
// IssueTrackingPhaseRefreshing means positive observations are being applied.
IssueTrackingPhaseRefreshing IssueTrackingPhase = "refreshing"
// IssueTrackingPhaseRefreshed means a refresh pass completed; later observations remain eligible.
IssueTrackingPhaseRefreshed IssueTrackingPhase = "refreshed"
// IssueTrackingPhaseFinalizing means final opening and resolution updates are pending.
IssueTrackingPhaseFinalizing IssueTrackingPhase = "finalizing"
// IssueTrackingPhaseFinalized means final reconciliation completed; later observations are ignored.
IssueTrackingPhaseFinalized IssueTrackingPhase = "finalized"
)

// IssueTrackingState is the queue-scoped source-order boundary and resumable reconciliation state.
type IssueTrackingState struct {
// RequestID is the newest accepted source request, compared by ingest order.
RequestID string `json:"request_id"`
// BuildID identifies the source build whose pass is pending or most recently completed.
BuildID string `json:"build_id"`
// Phase distinguishes pending work from completed observation or final passes.
Phase IssueTrackingPhase `json:"phase"`
// Version is the positive optimistic-lock version of this state.
Version int32 `json:"version"`
}

// IsPending reports whether the phase represents unfinished reconciliation.
func (s IssueTrackingState) IsPending() bool {
return s.Phase == IssueTrackingPhaseRefreshing || s.Phase == IssueTrackingPhaseFinalizing
}

// Validate verifies the identity, phase, and version of a persisted state.
func (s IssueTrackingState) Validate() error {
if err := id.Validate(s.RequestID); err != nil {
return fmt.Errorf("invalid issue tracking request ID: %w", err)
}
if s.BuildID == "" {
return fmt.Errorf("issue tracking build ID must not be empty")
}
if s.Version <= 0 {
return fmt.Errorf("issue tracking version must be positive")
}
switch s.Phase {
case IssueTrackingPhaseRefreshing, IssueTrackingPhaseRefreshed, IssueTrackingPhaseFinalizing, IssueTrackingPhaseFinalized:
return nil
default:
return fmt.Errorf("invalid issue tracking phase %q", s.Phase)
}
}
49 changes: 49 additions & 0 deletions stovepipe/entity/issue_tracking_state_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// 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 TestIssueTrackingState(t *testing.T) {
for _, phase := range []IssueTrackingPhase{IssueTrackingPhaseRefreshing, IssueTrackingPhaseRefreshed, IssueTrackingPhaseFinalizing, IssueTrackingPhaseFinalized} {
t.Run(string(phase), func(t *testing.T) {
state := IssueTrackingState{RequestID: "10", BuildID: "build/10", Phase: phase, Version: 1}
require.NoError(t, state.Validate())
require.Equal(t, phase == IssueTrackingPhaseRefreshing || phase == IssueTrackingPhaseFinalizing, state.IsPending())
})
}
for _, tt := range []struct {
name string
change func(*IssueTrackingState)
}{
{"missing request", func(s *IssueTrackingState) { s.RequestID = "" }},
{"noncanonical request", func(s *IssueTrackingState) { s.RequestID = "010" }},
{"nonnumeric request", func(s *IssueTrackingState) { s.RequestID = "request/10" }},
{"missing build", func(s *IssueTrackingState) { s.BuildID = "" }},
{"unknown phase", func(s *IssueTrackingState) { s.Phase = IssueTrackingPhaseUnknown }},
{"unsupported phase", func(s *IssueTrackingState) { s.Phase = "other" }},
{"zero version", func(s *IssueTrackingState) { s.Version = 0 }},
} {
t.Run(tt.name, func(t *testing.T) {
state := IssueTrackingState{RequestID: "10", BuildID: "build/10", Phase: IssueTrackingPhaseRefreshing, Version: 1}
tt.change(&state)
require.Error(t, state.Validate())
})
}
}
1 change: 1 addition & 0 deletions stovepipe/extension/storage/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ go_library(
"build_issue_store.go",
"build_store.go",
"issue_log_store.go",
"issue_tracking_store.go",
"open_issue_store.go",
"project_fact_by_degree_store.go",
"queue_log_store.go",
Expand Down
8 changes: 7 additions & 1 deletion 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`, `IssueLogStore`). Implementations live under `extension/storage/<impl>/`.
Pluggable persistence interfaces for Stovepipe entities (`RequestStore`, `RequestURIStore`, `QueueStore`, `BuildStore`, `BuildIssueStore`, `OpenIssueStore`, `IssueLogStore`, `IssueTrackingStore`). 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 @@ -59,3 +59,9 @@ The build ID identifies the build where the issue was first seen or confirmed re
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.

## Issue reconciliation progress

`IssueTrackingStore` retains one queue-scoped operational `IssueTrackingState`, independently of open-issue membership and user-facing summaries. It records the source request and build, whether observation or final writes are pending or complete, and its optimistic version. 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.

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.
34 changes: 34 additions & 0 deletions stovepipe/extension/storage/issue_tracking_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_tracking_store.go -destination=mock/issue_tracking_store_mock.go -package=mock

import (
"context"

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

// IssueTrackingStore retains one operational reconciliation state for the bound queue.
// It remains present when no issues are open so older results cannot recreate resolved issues.
type IssueTrackingStore interface {
// Get returns ErrNotFound before the queue's first reconciliation is accepted.
Get(context.Context) (entity.IssueTrackingState, error)
// Create returns ErrAlreadyExists when the queue already has a state.
Create(context.Context, entity.IssueTrackingState) error
// Update replaces the state only at oldVersion; callers supply newVersion without store arithmetic.
Update(ctx context.Context, state entity.IssueTrackingState, oldVersion, newVersion int32) 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 @@ -6,6 +6,7 @@ go_library(
"build_issue_store_mock.go",
"build_store_mock.go",
"issue_log_store_mock.go",
"issue_tracking_store_mock.go",
"open_issue_store_mock.go",
"project_fact_by_degree_store_mock.go",
"queue_log_store_mock.go",
Expand Down
85 changes: 85 additions & 0 deletions stovepipe/extension/storage/mock/issue_tracking_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 @@ -6,6 +6,7 @@ go_library(
"build_issue_store.go",
"build_store.go",
"issue_log_store.go",
"issue_tracking_store.go",
"open_issue_store.go",
"project_fact_by_degree_store.go",
"queue_log_store.go",
Expand Down Expand Up @@ -37,6 +38,7 @@ go_test(
"build_issue_store_test.go",
"build_store_test.go",
"issue_log_store_test.go",
"issue_tracking_store_test.go",
"open_issue_list_test.go",
"open_issue_store_test.go",
"project_fact_by_degree_store_test.go",
Expand Down
Loading
Loading