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
4 changes: 0 additions & 4 deletions stovepipe/entity/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,6 @@ type Build struct {
// 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
Expand Down
5 changes: 0 additions & 5 deletions stovepipe/entity/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,6 @@ func TestBuild_SerializationRoundTrip(t *testing.T) {
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,
},
Expand All @@ -63,7 +61,6 @@ func TestBuild_SerializationRoundTrip(t *testing.T) {
build: Build{
ID: "bk-1002",
RequestID: "request/monorepo/main/43",
URI: "git://remote/monorepo/main/feedface",
Status: BuildStatusSucceeded,
Version: 3,
},
Expand All @@ -73,8 +70,6 @@ func TestBuild_SerializationRoundTrip(t *testing.T) {
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,
},
Expand Down
1 change: 1 addition & 0 deletions stovepipe/extension/storage/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_library(
name = "go_default_library",
srcs = [
"build_store.go",
"queue_store.go",
"request_store.go",
"request_uri_store.go",
Expand Down
45 changes: 45 additions & 0 deletions stovepipe/extension/storage/build_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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 storage

//go:generate mockgen -source=build_store.go -destination=mock/build_store_mock.go -package=mock

import (
"context"

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

// BuildStore persists builds, keyed by build ID (the runner-assigned id minted at Trigger).
// build is the sole creator of a row; buildsignal is the sole writer of Status/Version
// afterward. No reverse index from Request to its builds is needed — buildsignal and record
// reach a build by the id carried in their messages.
type BuildStore interface {
// Create persists a new build. The build must have a unique ID already assigned.
// Returns ErrAlreadyExists if a build with the same ID already exists.
Create(ctx context.Context, build entity.Build) error

// Get retrieves a build by ID. Returns ErrNotFound if the build is not found.
Get(ctx context.Context, id string) (entity.Build, error)

// Update persists the mutable fields of build if the currently stored version matches
// oldVersion, writing newVersion as the new version. Returns ErrVersionMismatch if the
// stored version does not match (including when the build does not exist).
//
// Version arithmetic is owned by the caller: it computes newVersion (typically oldVersion+1)
// and only assigns build.Version = newVersion after this call succeeds. The store performs
// a pure conditional write and does not read build.Version.
Update(ctx context.Context, build entity.Build, 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 @@ -3,6 +3,7 @@ load("@rules_go//go:def.bzl", "go_library")
go_library(
name = "go_default_library",
srcs = [
"build_store_mock.go",
"queue_store_mock.go",
"request_store_mock.go",
"request_uri_store_mock.go",
Expand Down
85 changes: 85 additions & 0 deletions stovepipe/extension/storage/mock/build_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.

1 change: 1 addition & 0 deletions stovepipe/extension/storage/mysql/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_library(
name = "go_default_library",
srcs = [
"build_store.go",
"queue_store.go",
"request_store.go",
"request_uri_store.go",
Expand Down
130 changes: 130 additions & 0 deletions stovepipe/extension/storage/mysql/build_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright (c) 2025 Uber Technologies, Inc.
Comment thread
behinddwalls marked this conversation as resolved.
//
// 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 mysql

import (
"context"
"database/sql"
"errors"
"fmt"

"github.com/uber-go/tally"

"github.com/uber/submitqueue/platform/metrics"
"github.com/uber/submitqueue/stovepipe/entity"
"github.com/uber/submitqueue/stovepipe/extension/storage"
)

type buildStore struct {
db *sql.DB
scope tally.Scope
}

// NewBuildStore creates a new MySQL-backed BuildStore.
func NewBuildStore(db *sql.DB, scope tally.Scope) storage.BuildStore {
return &buildStore{db: db, scope: scope}
}

// Create persists a new build. Returns ErrAlreadyExists if the build ID already exists.
func (b *buildStore) Create(ctx context.Context, build entity.Build) (retErr error) {
op := metrics.Begin(b.scope, "create")
defer func() { op.Complete(retErr) }()

_, err := b.db.ExecContext(ctx,
`INSERT INTO build (id, request_id, status, version)
VALUES (?, ?, ?, ?)`,
build.ID,
build.RequestID,
build.Status,
build.Version,
)
if err != nil {
if isDuplicateEntry(err) {
return fmt.Errorf("build entity id=%s: %w", build.ID, storage.ErrAlreadyExists)
}
return fmt.Errorf("failed to insert build entity id=%s: %w", build.ID, err)
}

return nil
}

// Get retrieves a build by ID. Returns ErrNotFound if the build is not found.
func (b *buildStore) Get(ctx context.Context, id string) (ret entity.Build, retErr error) {
op := metrics.Begin(b.scope, "get")
defer func() { op.Complete(retErr) }()

var build entity.Build
err := b.db.QueryRowContext(ctx,
`SELECT id, request_id, status, version
FROM build WHERE id = ?`,
id,
).Scan(
&build.ID,
&build.RequestID,
&build.Status,
&build.Version,
)

if errors.Is(err, sql.ErrNoRows) {
return entity.Build{}, storage.WrapNotFound(err)
}
if err != nil {
return entity.Build{}, fmt.Errorf("failed to get build entity id=%s from the database: %w", id, err)
}

return build, nil
}

// Update persists the mutable fields of build (status) if the stored version matches
// oldVersion, writing newVersion. Returns ErrVersionMismatch if the stored version does not
// match (including when the build does not exist). This is a pure conditional write; the
// caller owns version arithmetic.
func (b *buildStore) Update(ctx context.Context, build entity.Build, oldVersion, newVersion int32) (retErr error) {
op := metrics.Begin(b.scope, "update")
defer func() { op.Complete(retErr) }()

result, err := b.db.ExecContext(ctx,
`UPDATE build
SET status = ?, version = ?
WHERE id = ? AND version = ?`,
build.Status,
newVersion,
build.ID,
oldVersion,
)
if err != nil {
return fmt.Errorf(
"failed to update build id=%q oldVersion=%d newVersion=%d: %w",
build.ID, oldVersion, newVersion, err,
)
}

rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf(
"failed to get rows affected from update for id=%q oldVersion=%d newVersion=%d: %w",
build.ID, oldVersion, newVersion, err,
)
}

if rowsAffected != 1 {
return fmt.Errorf(
"version mismatch for build update: id=%q expected_version=%d: %w",
build.ID, oldVersion, storage.ErrVersionMismatch,
)
}

return nil
}
9 changes: 9 additions & 0 deletions stovepipe/extension/storage/mysql/schema/build.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- build holds one CI build triggered for a request's commit. id is the runner-assigned build id
-- minted at Trigger (e.g. a Buildkite build number), opaque and never parsed or derived.
CREATE TABLE IF NOT EXISTS build (
id VARCHAR(255) NOT NULL,
request_id VARCHAR(255) NOT NULL,
status VARCHAR(64) NOT NULL,
version INT NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
7 changes: 7 additions & 0 deletions stovepipe/extension/storage/mysql/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type mysqlStorage struct {
requestStore storage.RequestStore
requestURIStore storage.RequestURIStore
queueStore storage.QueueStore
buildStore storage.BuildStore
}

// NewStorage creates a new MySQL-backed storage.
Expand All @@ -36,6 +37,7 @@ func NewStorage(db *sql.DB, scope tally.Scope) (storage.Storage, error) {
requestStore: NewRequestStore(db, scope.SubScope("request_store")),
requestURIStore: NewRequestURIStore(db, scope.SubScope("request_uri_store")),
queueStore: NewQueueStore(db, scope.SubScope("queue_store")),
buildStore: NewBuildStore(db, scope.SubScope("build_store")),
}, nil
}

Expand All @@ -54,6 +56,11 @@ func (f *mysqlStorage) GetQueueStore() storage.QueueStore {
return f.queueStore
}

// GetBuildStore returns the MySQL-backed BuildStore.
func (f *mysqlStorage) GetBuildStore() storage.BuildStore {
return f.buildStore
}

// Close closes the underlying database connection.
func (f *mysqlStorage) Close() error {
return f.db.Close()
Expand Down
3 changes: 3 additions & 0 deletions stovepipe/extension/storage/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ type Storage interface {
// GetQueueStore returns the QueueStore instance.
GetQueueStore() QueueStore

// GetBuildStore returns the BuildStore instance.
GetBuildStore() BuildStore

// Close closes the storage and all underlying connections. Should only be called once at the end of the program.
Close() error
}
Loading
Loading