diff --git a/stovepipe/entity/build.go b/stovepipe/entity/build.go index 4c194bc3..35c3e621 100644 --- a/stovepipe/entity/build.go +++ b/stovepipe/entity/build.go @@ -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 diff --git a/stovepipe/entity/build_test.go b/stovepipe/entity/build_test.go index 4d94c3b1..704c3cb6 100644 --- a/stovepipe/entity/build_test.go +++ b/stovepipe/entity/build_test.go @@ -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, }, @@ -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, }, @@ -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, }, diff --git a/stovepipe/extension/storage/BUILD.bazel b/stovepipe/extension/storage/BUILD.bazel index b4a2c8ac..168405b7 100644 --- a/stovepipe/extension/storage/BUILD.bazel +++ b/stovepipe/extension/storage/BUILD.bazel @@ -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", diff --git a/stovepipe/extension/storage/build_store.go b/stovepipe/extension/storage/build_store.go new file mode 100644 index 00000000..a6d188d0 --- /dev/null +++ b/stovepipe/extension/storage/build_store.go @@ -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 +} diff --git a/stovepipe/extension/storage/mock/BUILD.bazel b/stovepipe/extension/storage/mock/BUILD.bazel index cedba6ea..92930cce 100644 --- a/stovepipe/extension/storage/mock/BUILD.bazel +++ b/stovepipe/extension/storage/mock/BUILD.bazel @@ -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", diff --git a/stovepipe/extension/storage/mock/build_store_mock.go b/stovepipe/extension/storage/mock/build_store_mock.go new file mode 100644 index 00000000..83633282 --- /dev/null +++ b/stovepipe/extension/storage/mock/build_store_mock.go @@ -0,0 +1,85 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: build_store.go +// +// Generated by this command: +// +// mockgen -source=build_store.go -destination=mock/build_store_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + entity "github.com/uber/submitqueue/stovepipe/entity" + gomock "go.uber.org/mock/gomock" +) + +// MockBuildStore is a mock of BuildStore interface. +type MockBuildStore struct { + ctrl *gomock.Controller + recorder *MockBuildStoreMockRecorder + isgomock struct{} +} + +// MockBuildStoreMockRecorder is the mock recorder for MockBuildStore. +type MockBuildStoreMockRecorder struct { + mock *MockBuildStore +} + +// NewMockBuildStore creates a new mock instance. +func NewMockBuildStore(ctrl *gomock.Controller) *MockBuildStore { + mock := &MockBuildStore{ctrl: ctrl} + mock.recorder = &MockBuildStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockBuildStore) EXPECT() *MockBuildStoreMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockBuildStore) Create(ctx context.Context, build entity.Build) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, build) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockBuildStoreMockRecorder) Create(ctx, build any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockBuildStore)(nil).Create), ctx, build) +} + +// Get mocks base method. +func (m *MockBuildStore) Get(ctx context.Context, id string) (entity.Build, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, id) + ret0, _ := ret[0].(entity.Build) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockBuildStoreMockRecorder) Get(ctx, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockBuildStore)(nil).Get), ctx, id) +} + +// Update mocks base method. +func (m *MockBuildStore) Update(ctx context.Context, build entity.Build, oldVersion, newVersion int32) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", ctx, build, oldVersion, newVersion) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockBuildStoreMockRecorder) Update(ctx, build, oldVersion, newVersion any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockBuildStore)(nil).Update), ctx, build, oldVersion, newVersion) +} diff --git a/stovepipe/extension/storage/mock/storage_mock.go b/stovepipe/extension/storage/mock/storage_mock.go index 8e25638b..b2b8f013 100644 --- a/stovepipe/extension/storage/mock/storage_mock.go +++ b/stovepipe/extension/storage/mock/storage_mock.go @@ -54,6 +54,20 @@ func (mr *MockStorageMockRecorder) Close() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockStorage)(nil).Close)) } +// GetBuildStore mocks base method. +func (m *MockStorage) GetBuildStore() storage.BuildStore { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBuildStore") + ret0, _ := ret[0].(storage.BuildStore) + return ret0 +} + +// GetBuildStore indicates an expected call of GetBuildStore. +func (mr *MockStorageMockRecorder) GetBuildStore() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBuildStore", reflect.TypeOf((*MockStorage)(nil).GetBuildStore)) +} + // GetQueueStore mocks base method. func (m *MockStorage) GetQueueStore() storage.QueueStore { m.ctrl.T.Helper() diff --git a/stovepipe/extension/storage/mysql/BUILD.bazel b/stovepipe/extension/storage/mysql/BUILD.bazel index b10bb0ed..675f575c 100644 --- a/stovepipe/extension/storage/mysql/BUILD.bazel +++ b/stovepipe/extension/storage/mysql/BUILD.bazel @@ -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", diff --git a/stovepipe/extension/storage/mysql/build_store.go b/stovepipe/extension/storage/mysql/build_store.go new file mode 100644 index 00000000..45dd5aef --- /dev/null +++ b/stovepipe/extension/storage/mysql/build_store.go @@ -0,0 +1,130 @@ +// 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 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 +} diff --git a/stovepipe/extension/storage/mysql/schema/build.sql b/stovepipe/extension/storage/mysql/schema/build.sql new file mode 100644 index 00000000..21106ded --- /dev/null +++ b/stovepipe/extension/storage/mysql/schema/build.sql @@ -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; diff --git a/stovepipe/extension/storage/mysql/storage.go b/stovepipe/extension/storage/mysql/storage.go index 19f55299..eac6fdcc 100644 --- a/stovepipe/extension/storage/mysql/storage.go +++ b/stovepipe/extension/storage/mysql/storage.go @@ -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. @@ -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 } @@ -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() diff --git a/stovepipe/extension/storage/storage.go b/stovepipe/extension/storage/storage.go index 0d16ea3b..46eee6c7 100644 --- a/stovepipe/extension/storage/storage.go +++ b/stovepipe/extension/storage/storage.go @@ -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 } diff --git a/test/integration/stovepipe/extension/storage/mysql/storage_test.go b/test/integration/stovepipe/extension/storage/mysql/storage_test.go index d293c2e9..8ab93de1 100644 --- a/test/integration/stovepipe/extension/storage/mysql/storage_test.go +++ b/test/integration/stovepipe/extension/storage/mysql/storage_test.go @@ -255,3 +255,51 @@ func (s *MySQLQueueStoreSuite) SetupSuite() { } }) } + +// MySQLBuildStoreSuite exercises the MySQL-backed BuildStore by embedding the shared contract suite. +type MySQLBuildStoreSuite struct { + storagesuite.BuildStoreContractSuite + stack *testutil.ComposeStack + db *sql.DB + log *testutil.TestLogger +} + +func TestMySQLBuildStore(t *testing.T) { + suite.Run(t, new(MySQLBuildStoreSuite)) +} + +func (s *MySQLBuildStoreSuite) SetupSuite() { + t := s.T() + ctx := context.Background() + s.log = testutil.NewTestLogger(t) + + s.stack = testutil.NewComposeStack( + t, + s.log, + ctx, + "docker-compose.yml", + "ext-stovepipe-storage-mysql", + ) + + err := s.stack.Up() + require.NoError(t, err, "failed to start compose stack") + + s.db, err = s.stack.ConnectMySQLService("mysql") + require.NoError(t, err, "failed to connect to MySQL") + + schemaDir := testutil.SchemaDir("stovepipe/extension/storage/mysql/schema") + testutil.ApplySchema(t, s.log, s.db, schemaDir) + + store, err := mysqlstorage.NewStorage(s.db, tally.NoopScope) + require.NoError(t, err, "failed to create storage") + + s.SetContext(ctx) + s.SetBuildStore(store.GetBuildStore()) + s.SetLogger(s.log) + + t.Cleanup(func() { + if s.db != nil { + s.db.Close() + } + }) +} diff --git a/test/integration/stovepipe/extension/storage/suite.go b/test/integration/stovepipe/extension/storage/suite.go index a54bf2bd..42965ee9 100644 --- a/test/integration/stovepipe/extension/storage/suite.go +++ b/test/integration/stovepipe/extension/storage/suite.go @@ -186,6 +186,186 @@ func (s *QueueStoreContractSuite) TestQueueStore_UpdateSequentialCAS() { s.log.Logf("UpdateSequentialCAS passed: queue %s", name) } +// BuildStoreContractSuite defines contract tests for storage.BuildStore. +// All BuildStore implementations must pass these tests. +type BuildStoreContractSuite struct { + suite.Suite + ctx context.Context + buildStore storage.BuildStore + log *testutil.TestLogger +} + +// SetContext sets the context for tests. +func (s *BuildStoreContractSuite) SetContext(ctx context.Context) { + s.ctx = ctx +} + +// SetBuildStore provides the concrete BuildStore under test. +func (s *BuildStoreContractSuite) SetBuildStore(store storage.BuildStore) { + s.buildStore = store +} + +// SetLogger sets the logger for tests. +func (s *BuildStoreContractSuite) SetLogger(log *testutil.TestLogger) { + s.log = log +} + +// TestBuildStore_CreateAndGet verifies Create persists caller-supplied fields, readable via Get. +func (s *BuildStoreContractSuite) TestBuildStore_CreateAndGet() { + t := s.T() + const id = "contract/create" + + build := entity.Build{ + ID: id, + RequestID: "request/contract/create/1", + Status: entity.BuildStatusAccepted, + Version: 1, + } + require.NoError(t, s.buildStore.Create(s.ctx, build)) + + got, err := s.buildStore.Get(s.ctx, id) + require.NoError(t, err) + assert.Equal(t, build, got) + + s.log.Logf("CreateAndGet passed: created build %s", id) +} + +// TestBuildStore_CreateAlreadyExists verifies a duplicate Create returns ErrAlreadyExists. +func (s *BuildStoreContractSuite) TestBuildStore_CreateAlreadyExists() { + t := s.T() + const id = "contract/already-exists" + + first := entity.Build{ + ID: id, + RequestID: "request/contract/already-exists/1", + Status: entity.BuildStatusAccepted, + Version: 1, + } + require.NoError(t, s.buildStore.Create(s.ctx, first)) + + err := s.buildStore.Create(s.ctx, entity.Build{ + ID: id, + RequestID: "request/contract/already-exists/ignored-on-race", + Status: entity.BuildStatusRunning, + Version: 1, + }) + assert.ErrorIs(t, err, storage.ErrAlreadyExists) + + got, err := s.buildStore.Get(s.ctx, id) + require.NoError(t, err) + assert.Equal(t, first, got) + + s.log.Logf("CreateAlreadyExists passed: build %s", id) +} + +// TestBuildStore_GetNotFound verifies Get returns ErrNotFound for a missing build. +func (s *BuildStoreContractSuite) TestBuildStore_GetNotFound() { + t := s.T() + + _, err := s.buildStore.Get(s.ctx, "contract/does-not-exist") + assert.True(t, storage.IsNotFound(err)) + + s.log.Logf("GetNotFound passed") +} + +// TestBuildStore_UpdateCAS verifies a conditional update persists the new status and rejects stale versions. +func (s *BuildStoreContractSuite) TestBuildStore_UpdateCAS() { + t := s.T() + const id = "contract/update-cas" + + created := entity.Build{ + ID: id, + RequestID: "request/contract/update-cas/1", + Status: entity.BuildStatusAccepted, + Version: 1, + } + require.NoError(t, s.buildStore.Create(s.ctx, created)) + + updated := created + updated.Status = entity.BuildStatusRunning + require.NoError(t, s.buildStore.Update(s.ctx, updated, 1, 2)) + + got, err := s.buildStore.Get(s.ctx, id) + require.NoError(t, err) + assert.Equal(t, entity.BuildStatusRunning, got.Status) + assert.Equal(t, int32(2), got.Version) + + err = s.buildStore.Update(s.ctx, updated, 1, 2) + assert.ErrorIs(t, err, storage.ErrVersionMismatch) + + s.log.Logf("UpdateCAS passed: build %s", id) +} + +// TestBuildStore_UpdateNotFoundIsVersionMismatch verifies Update on a missing row returns ErrVersionMismatch. +func (s *BuildStoreContractSuite) TestBuildStore_UpdateNotFoundIsVersionMismatch() { + t := s.T() + + err := s.buildStore.Update(s.ctx, entity.Build{ID: "contract/missing"}, 1, 2) + assert.ErrorIs(t, err, storage.ErrVersionMismatch) + + s.log.Logf("UpdateNotFoundIsVersionMismatch passed") +} + +// TestBuildStore_UpdateSequentialCAS verifies successive conditional updates advance version monotonically +// and are write-once on terminal status, per build.md's algorithm step 6. +func (s *BuildStoreContractSuite) TestBuildStore_UpdateSequentialCAS() { + t := s.T() + const id = "contract/sequential-cas" + + require.NoError(t, s.buildStore.Create(s.ctx, entity.Build{ + ID: id, + RequestID: "request/contract/sequential-cas/1", + Status: entity.BuildStatusAccepted, + Version: 1, + })) + + v2 := entity.Build{ID: id, Status: entity.BuildStatusRunning, Version: 1} + require.NoError(t, s.buildStore.Update(s.ctx, v2, 1, 2)) + + v3 := entity.Build{ID: id, Status: entity.BuildStatusSucceeded, Version: 2} + require.NoError(t, s.buildStore.Update(s.ctx, v3, 2, 3)) + + got, err := s.buildStore.Get(s.ctx, id) + require.NoError(t, err) + assert.Equal(t, entity.BuildStatusSucceeded, got.Status) + assert.Equal(t, int32(3), got.Version) + + s.log.Logf("UpdateSequentialCAS passed: build %s", id) +} + +// TestBuildStore_QueueIsolation verifies updates to one build do not affect another. +func (s *BuildStoreContractSuite) TestBuildStore_QueueIsolation() { + t := s.T() + const ( + idA = "contract/isolation-a" + idB = "contract/isolation-b" + ) + + require.NoError(t, s.buildStore.Create(s.ctx, entity.Build{ + ID: idA, RequestID: "request/contract/isolation-a/1", + Status: entity.BuildStatusAccepted, Version: 1, + })) + require.NoError(t, s.buildStore.Create(s.ctx, entity.Build{ + ID: idB, RequestID: "request/contract/isolation-b/1", + Status: entity.BuildStatusAccepted, Version: 1, + })) + + baseline, err := s.buildStore.Get(s.ctx, idB) + require.NoError(t, err) + + updatedA := entity.Build{ + ID: idA, RequestID: "request/contract/isolation-a/1", + Status: entity.BuildStatusRunning, Version: 1, + } + require.NoError(t, s.buildStore.Update(s.ctx, updatedA, 1, 2)) + + gotB, err := s.buildStore.Get(s.ctx, idB) + require.NoError(t, err) + assert.Equal(t, baseline, gotB) + + s.log.Logf("QueueIsolation passed: builds %s and %s", idA, idB) +} + // TestQueueStore_QueueIsolation verifies updates to one queue do not affect another. func (s *QueueStoreContractSuite) TestQueueStore_QueueIsolation() { t := s.T()