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 @@ -3,6 +3,7 @@ load("@rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "go_default_library",
srcs = [
"build.go",
"ingest.go",
"queue.go",
"queue_config.go",
Expand All @@ -16,6 +17,7 @@ go_library(
go_test(
name = "go_default_test",
srcs = [
"build_test.go",
"request_id_test.go",
"request_test.go",
],
Expand Down
118 changes: 118 additions & 0 deletions stovepipe/entity/build.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2025 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 "encoding/json"

// BuildStatus defines the possible states of a build. Shaped the same as
// SubmitQueue's own BuildStatus (submitqueue/entity/build.go), but defined
// locally rather than shared — see build.md's "Alternatives considered for
// sharing the contract".
type BuildStatus string

const (
// BuildStatusUnknown is the unreachable zero value, set by default when
// the structure is initialized. It should never be seen in the system.
BuildStatusUnknown BuildStatus = ""

// BuildStatusAccepted indicates the build has been accepted for
// execution by the runner via Trigger, but has not yet started.
BuildStatusAccepted BuildStatus = "accepted"

// BuildStatusRunning indicates the build is currently executing.
BuildStatusRunning BuildStatus = "running"

// BuildStatusSucceeded indicates the build completed successfully.
// This is a terminal state.
BuildStatusSucceeded BuildStatus = "succeeded"

// BuildStatusFailed indicates the build did not complete successfully.
// This is a terminal state.
BuildStatusFailed BuildStatus = "failed"

// BuildStatusCancelled indicates the build was cancelled.
// This is a terminal state.
BuildStatusCancelled BuildStatus = "cancelled"
)

// IsTerminal returns true if the status represents a final state
// (Succeeded, Failed, or Cancelled). A terminal status is write-once: once
// buildsignal persists one, a later poll reporting a different terminal
// value must never overwrite it (see buildsignal.md's Algorithm, step 6).
func (s BuildStatus) IsTerminal() bool {
return s == BuildStatusSucceeded || s == BuildStatusFailed || s == BuildStatusCancelled
}

// Build represents a single build triggered for a Request's commit. All
// fields except Status and Version are immutable after creation — build is
// the sole creator (via BuildStore.Create), and buildsignal is the sole
// writer of Status/Version afterward.
type Build struct {
// ID is the build's own key: the runner-assigned id returned by
// Trigger (e.g. a Buildkite build number). Opaque; never parsed or
// derived by stovepipe.
ID string `json:"id"`
// RequestID is the Request this build validates (Build->Request
// navigation; no reverse index from Request to its builds is needed).
RequestID string `json:"request_id"`
// URI is the head URI being built (== Request.URI).
URI string `json:"uri"`
// BaseURI is the incremental baseline; empty for full builds.
BaseURI string `json:"base_uri"`
// Status is the build's lifecycle state.
Status BuildStatus `json:"status"`
// Version is used for optimistic locking. Versioning starts at 1 and
// is incremented for each change to the object.
Version int32 `json:"version"`
}

// ToBytes serializes the Build to JSON bytes for queue message payload.
func (b Build) ToBytes() ([]byte, error) {
return json.Marshal(b)
}

// BuildFromBytes deserializes a Build from JSON bytes.
func BuildFromBytes(data []byte) (Build, error) {
var build Build
err := json.Unmarshal(data, &build)
return build, err
}

// BuildID is a lightweight entity for publishing and consuming just the
// build identifier via the queue, and for the BuildRunner Status/Cancel
// parameter. It wraps the one runner-assigned id everywhere it appears.
type BuildID struct {
// ID is the runner-assigned identifier for the build.
ID string `json:"id"`
}

// ToBytes serializes the BuildID to JSON bytes for queue message payload.
func (b BuildID) ToBytes() ([]byte, error) {
return json.Marshal(b)
}

// BuildIDFromBytes deserializes a BuildID from JSON bytes.
func BuildIDFromBytes(data []byte) (BuildID, error) {
var bid BuildID
err := json.Unmarshal(data, &bid)
return bid, err
}

// BuildMetadata carries caller-supplied, provider-echoed free-form metadata
// about a build. The runner must not depend on its contents. Empty today;
// expected to carry real data eventually (e.g. conflict-graph info, or other
// upstream decisions relevant to the build) once a concrete need lands in
// either domain — the shape is deferred until then, not decided here.
type BuildMetadata map[string]string
127 changes: 127 additions & 0 deletions stovepipe/entity/build_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) 2025 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/assert"
"github.com/stretchr/testify/require"
)

func TestBuildStatus_IsTerminal(t *testing.T) {
tests := []struct {
name string
status BuildStatus
expected bool
}{
{name: "succeeded is terminal", status: BuildStatusSucceeded, expected: true},
{name: "failed is terminal", status: BuildStatusFailed, expected: true},
{name: "cancelled is terminal", status: BuildStatusCancelled, expected: true},
{name: "accepted is not terminal", status: BuildStatusAccepted, expected: false},
{name: "running is not terminal", status: BuildStatusRunning, expected: false},
{name: "unknown is not terminal", status: BuildStatusUnknown, expected: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.status.IsTerminal())
})
}
}

func TestBuild_SerializationRoundTrip(t *testing.T) {
tests := []struct {
name string
build Build
}{
{
name: "accepted incremental build",
build: Build{
ID: "bk-1001",
RequestID: "request/monorepo/main/42",
URI: "git://remote/monorepo/main/deadbeef",
BaseURI: "git://remote/monorepo/main/cafef00d",
Status: BuildStatusAccepted,
Version: 1,
},
},
{
name: "succeeded full build with no baseline",
build: Build{
ID: "bk-1002",
RequestID: "request/monorepo/main/43",
URI: "git://remote/monorepo/main/feedface",
Status: BuildStatusSucceeded,
Version: 3,
},
},
{
name: "failed build",
build: Build{
ID: "bk-1003",
RequestID: "request/monorepo/main/44",
URI: "git://remote/monorepo/main/0ff1ce",
BaseURI: "git://remote/monorepo/main/cafef00d",
Status: BuildStatusFailed,
Version: 2,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := tt.build.ToBytes()
require.NoError(t, err)

deserialized, err := BuildFromBytes(data)
require.NoError(t, err)

assert.Equal(t, tt.build, deserialized)
})
}
}

func TestBuildFromBytes_InvalidJSON(t *testing.T) {
_, err := BuildFromBytes([]byte(`{"invalid": json"}`))
assert.Error(t, err)
}

func TestBuildFromBytes_EmptyData(t *testing.T) {
build, err := BuildFromBytes([]byte(`{}`))
require.NoError(t, err)

assert.Empty(t, build.ID)
assert.Empty(t, build.RequestID)
assert.Equal(t, BuildStatusUnknown, build.Status)
assert.Equal(t, int32(0), build.Version)
}

func TestBuildID_SerializationRoundTrip(t *testing.T) {
original := BuildID{ID: "bk-1001"}

data, err := original.ToBytes()
require.NoError(t, err)

deserialized, err := BuildIDFromBytes(data)
require.NoError(t, err)

assert.Equal(t, original, deserialized)
}

func TestBuildIDFromBytes_InvalidJSON(t *testing.T) {
_, err := BuildIDFromBytes([]byte(`{"invalid": json"}`))
assert.Error(t, err)
}
13 changes: 13 additions & 0 deletions stovepipe/entity/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,21 @@ const (
RequestStateProcessing RequestState = "processing"
// RequestStateSuperseded means process skipped the request because a newer head exists.
RequestStateSuperseded RequestState = "superseded"
// RequestStateRecordedGreen means record wrote whole-repo greenness as green for this URI.
RequestStateRecordedGreen RequestState = "recorded_green"
// RequestStateRecordedNotGreen means record wrote whole-repo greenness as not-green for this
// URI — either a genuine build failure, or a conservative verdict forced by the DLQ
// reconciler when the request could never complete (see workflow.md's fail-closed posture).
RequestStateRecordedNotGreen RequestState = "recorded_not_green"
)

// IsTerminal returns true if the state is one the pipeline never advances past: superseded (a
// newer head preempted this request) or either recorded outcome (record wrote greenness for
// this URI, whether via the normal path or the fail-closed reconciler).
func (s RequestState) IsTerminal() bool {
return s == RequestStateSuperseded || s == RequestStateRecordedGreen || s == RequestStateRecordedNotGreen
}

// BuildStrategy defines how build validates the request's commit.
type BuildStrategy string

Expand Down
21 changes: 21 additions & 0 deletions stovepipe/entity/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,27 @@ func TestRequestFromBytes_EmptyData(t *testing.T) {
assert.Equal(t, int32(0), req.Version)
}

func TestRequestState_IsTerminal(t *testing.T) {
tests := []struct {
name string
state RequestState
expected bool
}{
{name: "superseded is terminal", state: RequestStateSuperseded, expected: true},
{name: "recorded green is terminal", state: RequestStateRecordedGreen, expected: true},
{name: "recorded not green is terminal", state: RequestStateRecordedNotGreen, expected: true},
{name: "unknown is not terminal", state: RequestStateUnknown, expected: false},
{name: "accepted is not terminal", state: RequestStateAccepted, expected: false},
{name: "processing is not terminal", state: RequestStateProcessing, expected: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.state.IsTerminal())
})
}
}

func TestRequestID_SerializationRoundTrip(t *testing.T) {
original := RequestID{ID: "request/monorepo/main/100"}

Expand Down
Loading