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 @@ -8,6 +8,7 @@ go_library(
"ingest.go",
"issue_log.go",
"issue_state.go",
"issue_summary.go",
"issue_tracking_state.go",
"list.go",
"open_issue.go",
Expand Down Expand Up @@ -38,6 +39,7 @@ go_test(
"build_test.go",
"issue_log_test.go",
"issue_state_test.go",
"issue_summary_test.go",
"issue_tracking_state_test.go",
"project_fact_by_degree_test.go",
"queue_state_test.go",
Expand Down
62 changes: 62 additions & 0 deletions stovepipe/entity/issue_summary.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// 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"
)

// IssueSummary is the latest read model of an issue within a queue.
// Closed summaries retain the source of their latest transition.
type IssueSummary struct {
// TaskID identifies the logical validation task and must be nonempty.
TaskID string
// UnitID identifies the validation unit; empty means a task-wide issue.
UnitID string
// Status identifies whether the issue is open or closed.
Status IssueStatus
// FirstSeenBuildID identifies the current or most recent opening.
FirstSeenBuildID string
// RequestID is the canonical decimal source request ID of the latest transition.
RequestID string
// BuildID identifies the source build of the latest transition.
BuildID string
// ChangedAtMs is when the latest transition was first recorded, in Unix milliseconds.
ChangedAtMs int64
// Version is the positive optimistic-lock version of this summary.
Version int32
}

// Validate checks the identity, status, timestamp, and version of a summary.
func (s IssueSummary) Validate() error {
if s.TaskID == "" || s.FirstSeenBuildID == "" || s.BuildID == "" {
return fmt.Errorf("issue summary requires task, first-seen build, and source build IDs")
}
if err := id.Validate(s.RequestID); err != nil {
return fmt.Errorf("invalid issue summary request ID: %w", err)
}
if s.Status != IssueStatusOpen && s.Status != IssueStatusClosed {
return fmt.Errorf("invalid issue summary status %q", s.Status)
}
if s.ChangedAtMs <= 0 {
return fmt.Errorf("issue summary requires a positive change timestamp")
}
if s.Version <= 0 {
return fmt.Errorf("issue summary requires a positive version")
}
return nil
}
60 changes: 60 additions & 0 deletions stovepipe/entity/issue_summary_test.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 (
"testing"

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

func TestIssueSummaryValidate(t *testing.T) {
valid := IssueSummary{TaskID: "validation", UnitID: "unit", Status: IssueStatusOpen,
FirstSeenBuildID: "build/1", RequestID: "1", BuildID: "build/1", ChangedAtMs: 1000, Version: 1}
tests := []struct {
name string
change func(*IssueSummary)
wantErr bool
}{
{name: "open"},
{name: "closed", change: func(s *IssueSummary) { s.Status = IssueStatusClosed }},
{name: "task wide", change: func(s *IssueSummary) { s.UnitID = "" }},
{"missing task", func(s *IssueSummary) { s.TaskID = "" }, true},
{"missing opening", func(s *IssueSummary) { s.FirstSeenBuildID = "" }, true},
{"missing source build", func(s *IssueSummary) { s.BuildID = "" }, true},
{"missing source request", func(s *IssueSummary) { s.RequestID = "" }, true},
{"noncanonical source request", func(s *IssueSummary) { s.RequestID = "01" }, true},
{"invalid source request", func(s *IssueSummary) { s.RequestID = "build/1" }, true},
{"unknown status", func(s *IssueSummary) { s.Status = IssueStatusUnknown }, true},
{"invalid status", func(s *IssueSummary) { s.Status = "other" }, true},
{"missing timestamp", func(s *IssueSummary) { s.ChangedAtMs = 0 }, true},
{"negative timestamp", func(s *IssueSummary) { s.ChangedAtMs = -1 }, true},
{"missing version", func(s *IssueSummary) { s.Version = 0 }, true},
{"negative version", func(s *IssueSummary) { s.Version = -1 }, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summary := valid
if tt.change != nil {
tt.change(&summary)
}
if tt.wantErr {
require.Error(t, summary.Validate())
} else {
require.NoError(t, summary.Validate())
}
})
}
}
1 change: 1 addition & 0 deletions stovepipe/extension/storage/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ go_library(
"build_store.go",
"issue_log_store.go",
"issue_state_store.go",
"issue_summary_store.go",
"issue_tracking_store.go",
"project_fact_by_degree_store.go",
"queue_log_store.go",
Expand Down
8 changes: 8 additions & 0 deletions stovepipe/extension/storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ Listing reads a bounded primary-key range in bytewise task/unit order and includ

The MySQL backend uses the new `issue_state` table. Create it before deploying the updated readers. The prior `open_issue` table is not read or automatically copied; no in-repository runtime writer was enabled for it. Deployments with externally populated rows need an explicit backfill before switching readers. The repair executor still receives an `OpenIssue`, which describes an open occurrence rather than the storage record.

## Issue summaries

`IssueSummaryStore` retains one read model per task/unit within its queue, independently of operational `IssueState`. It supports only complete-key create, get, and CAS replacement. Closed summaries remain present; removing them would also remove the source-order information needed to reject an older opening. There is no deletion or listing operation.

`RequestID` and `BuildID` identify the latest represented transition, while `FirstSeenBuildID` identifies its current or most recent opening. Source order follows request ingest order, not opaque build IDs or timestamps. Within the same request, a closing transition supersedes an opening. `Version` is only the summary's CAS guard, not the source order. The materializer must compare incoming transitions before replacing a summary; the store does not enforce lifecycle ordering.

The MySQL primary key is `(queue, task_id, unit_id)` with byte-exact identity and the same identifier limits as the issue-state store. Source request IDs follow the existing canonical positive decimal ID contract. The new `issue_summary` table must exist before deploying readers or writers. This storage layer does not yet populate summaries or expose a public open-issues list.

## Issue lifecycle history

`IssueLogStore` retains immutable open/closed status transitions independently of current issue state and per-build observations. Each transition is keyed by task, unit, build, and status within the bound queue. An empty unit identifies a task-wide issue. History remains when an issue closes or reopens; transitions do not carry a separate reference to the opening they belong to.
Expand Down
37 changes: 37 additions & 0 deletions stovepipe/extension/storage/issue_summary_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// 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_summary_store.go -destination=mock/issue_summary_store_mock.go -package=mock

import (
"context"

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

// IssueSummaryStore retains queue-scoped issue read models, including closed issues.
// Source ordering is the caller's responsibility; Version guards concurrent replacements.
type IssueSummaryStore interface {
// Create returns ErrAlreadyExists if the task/unit key exists, even when closed.
Create(ctx context.Context, summary entity.IssueSummary) error

// Get returns ErrNotFound if the task/unit key has no summary.
Get(ctx context.Context, taskID, unitID string) (entity.IssueSummary, error)

// Update replaces summary only at oldVersion, writing the supplied newVersion.
// A missing key or stale version returns ErrVersionMismatch.
Update(ctx context.Context, summary entity.IssueSummary, 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 @@ -7,6 +7,7 @@ go_library(
"build_store_mock.go",
"issue_log_store_mock.go",
"issue_state_store_mock.go",
"issue_summary_store_mock.go",
"issue_tracking_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_summary_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 @@ -7,6 +7,7 @@ go_library(
"build_store.go",
"issue_log_store.go",
"issue_state_store.go",
"issue_summary_store.go",
"issue_tracking_store.go",
"project_fact_by_degree_store.go",
"queue_log_store.go",
Expand Down Expand Up @@ -41,6 +42,7 @@ go_test(
"issue_log_store_test.go",
"issue_state_list_test.go",
"issue_state_store_test.go",
"issue_summary_store_test.go",
"issue_tracking_store_test.go",
"project_fact_by_degree_store_test.go",
"queue_store_test.go",
Expand Down
Loading
Loading