diff --git a/stovepipe/extension/storage/BUILD.bazel b/stovepipe/extension/storage/BUILD.bazel index 801976fb..b4a2c8ac 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 = [ + "queue_store.go", "request_store.go", "request_uri_store.go", "storage.go", diff --git a/stovepipe/extension/storage/mock/BUILD.bazel b/stovepipe/extension/storage/mock/BUILD.bazel index d4058d73..cedba6ea 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 = [ + "queue_store_mock.go", "request_store_mock.go", "request_uri_store_mock.go", "storage_mock.go", diff --git a/stovepipe/extension/storage/mock/queue_store_mock.go b/stovepipe/extension/storage/mock/queue_store_mock.go new file mode 100644 index 00000000..d69e9e9c --- /dev/null +++ b/stovepipe/extension/storage/mock/queue_store_mock.go @@ -0,0 +1,86 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: queue_store.go +// +// Generated by this command: +// +// mockgen -source=queue_store.go -destination=mock/queue_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" +) + +// MockQueueStore is a mock of QueueStore interface. +type MockQueueStore struct { + ctrl *gomock.Controller + recorder *MockQueueStoreMockRecorder + isgomock struct{} +} + +// MockQueueStoreMockRecorder is the mock recorder for MockQueueStore. +type MockQueueStoreMockRecorder struct { + mock *MockQueueStore +} + +// NewMockQueueStore creates a new mock instance. +func NewMockQueueStore(ctrl *gomock.Controller) *MockQueueStore { + mock := &MockQueueStore{ctrl: ctrl} + mock.recorder = &MockQueueStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockQueueStore) EXPECT() *MockQueueStoreMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockQueueStore) Get(ctx context.Context, name string) (entity.Queue, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, name) + ret0, _ := ret[0].(entity.Queue) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockQueueStoreMockRecorder) Get(ctx, name any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockQueueStore)(nil).Get), ctx, name) +} + +// GetOrCreate mocks base method. +func (m *MockQueueStore) GetOrCreate(ctx context.Context, name string, defaults entity.Queue) (entity.Queue, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetOrCreate", ctx, name, defaults) + ret0, _ := ret[0].(entity.Queue) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetOrCreate indicates an expected call of GetOrCreate. +func (mr *MockQueueStoreMockRecorder) GetOrCreate(ctx, name, defaults any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrCreate", reflect.TypeOf((*MockQueueStore)(nil).GetOrCreate), ctx, name, defaults) +} + +// Update mocks base method. +func (m *MockQueueStore) Update(ctx context.Context, queue entity.Queue, oldVersion, newVersion int32) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", ctx, queue, oldVersion, newVersion) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockQueueStoreMockRecorder) Update(ctx, queue, oldVersion, newVersion any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockQueueStore)(nil).Update), ctx, queue, oldVersion, newVersion) +} diff --git a/stovepipe/extension/storage/mock/storage_mock.go b/stovepipe/extension/storage/mock/storage_mock.go index 7da8ebb9..8e25638b 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)) } +// GetQueueStore mocks base method. +func (m *MockStorage) GetQueueStore() storage.QueueStore { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetQueueStore") + ret0, _ := ret[0].(storage.QueueStore) + return ret0 +} + +// GetQueueStore indicates an expected call of GetQueueStore. +func (mr *MockStorageMockRecorder) GetQueueStore() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetQueueStore", reflect.TypeOf((*MockStorage)(nil).GetQueueStore)) +} + // GetRequestStore mocks base method. func (m *MockStorage) GetRequestStore() storage.RequestStore { m.ctrl.T.Helper() diff --git a/stovepipe/extension/storage/mysql/BUILD.bazel b/stovepipe/extension/storage/mysql/BUILD.bazel index 28fda8eb..b10bb0ed 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 = [ + "queue_store.go", "request_store.go", "request_uri_store.go", "storage.go", diff --git a/stovepipe/extension/storage/mysql/queue_store.go b/stovepipe/extension/storage/mysql/queue_store.go new file mode 100644 index 00000000..223a8374 --- /dev/null +++ b/stovepipe/extension/storage/mysql/queue_store.go @@ -0,0 +1,153 @@ +// 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 queueStore struct { + db *sql.DB + scope tally.Scope +} + +// NewQueueStore creates a new MySQL-backed QueueStore. +func NewQueueStore(db *sql.DB, scope tally.Scope) storage.QueueStore { + return &queueStore{db: db, scope: scope} +} + +// GetOrCreate returns the queue row for name, creating it with defaults if absent. +func (q *queueStore) GetOrCreate(ctx context.Context, name string, defaults entity.Queue) (ret entity.Queue, retErr error) { + op := metrics.Begin(q.scope, "get_or_create") + defer func() { op.Complete(retErr) }() + + queue, err := q.Get(ctx, name) + if err == nil { + return queue, nil + } + if !storage.IsNotFound(err) { + return entity.Queue{}, err + } + + toCreate := defaults + toCreate.Name = name + if toCreate.Version == 0 { + toCreate.Version = 1 + } + + if err := q.create(ctx, toCreate); err != nil { + if errors.Is(err, storage.ErrAlreadyExists) { + return q.Get(ctx, name) + } + return entity.Queue{}, err + } + return toCreate, nil +} + +// Get retrieves a queue by name. Returns ErrNotFound if the queue is not found. +func (q *queueStore) Get(ctx context.Context, name string) (ret entity.Queue, retErr error) { + op := metrics.Begin(q.scope, "get") + defer func() { op.Complete(retErr) }() + + var queue entity.Queue + err := q.db.QueryRowContext(ctx, + "SELECT name, last_green_uri, in_flight_count, latest_request_seq, version FROM queue WHERE name = ?", + name, + ).Scan( + &queue.Name, + &queue.LastGreenURI, + &queue.InFlightCount, + &queue.LatestRequestSeq, + &queue.Version, + ) + + if errors.Is(err, sql.ErrNoRows) { + return entity.Queue{}, storage.WrapNotFound(err) + } + if err != nil { + return entity.Queue{}, fmt.Errorf("failed to get queue name=%s from the database: %w", name, err) + } + + return queue, nil +} + +// Update persists the mutable fields of queue if the stored version matches oldVersion, +// writing newVersion. Returns ErrVersionMismatch if the stored version does not match. +func (q *queueStore) Update(ctx context.Context, queue entity.Queue, oldVersion, newVersion int32) (retErr error) { + op := metrics.Begin(q.scope, "update") + defer func() { op.Complete(retErr) }() + + result, err := q.db.ExecContext(ctx, + `UPDATE queue + SET last_green_uri = ?, in_flight_count = ?, latest_request_seq = ?, version = ? + WHERE name = ? AND version = ?`, + queue.LastGreenURI, + queue.InFlightCount, + queue.LatestRequestSeq, + newVersion, + queue.Name, + oldVersion, + ) + if err != nil { + return fmt.Errorf( + "failed to update queue name=%q oldVersion=%d newVersion=%d: %w", + queue.Name, oldVersion, newVersion, err, + ) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf( + "failed to get rows affected from update for name=%q oldVersion=%d newVersion=%d: %w", + queue.Name, oldVersion, newVersion, err, + ) + } + + if rowsAffected != 1 { + return fmt.Errorf( + "version mismatch for queue update: name=%q expected_version=%d: %w", + queue.Name, oldVersion, storage.ErrVersionMismatch, + ) + } + + return nil +} + +func (q *queueStore) create(ctx context.Context, queue entity.Queue) error { + _, err := q.db.ExecContext(ctx, + `INSERT INTO queue (name, last_green_uri, in_flight_count, latest_request_seq, version) + VALUES (?, ?, ?, ?, ?)`, + queue.Name, + queue.LastGreenURI, + queue.InFlightCount, + queue.LatestRequestSeq, + queue.Version, + ) + if err != nil { + if isDuplicateEntry(err) { + return fmt.Errorf("queue name=%s: %w", queue.Name, storage.ErrAlreadyExists) + } + return fmt.Errorf("failed to insert queue name=%s: %w", queue.Name, err) + } + return nil +} diff --git a/stovepipe/extension/storage/mysql/schema/queue.sql b/stovepipe/extension/storage/mysql/schema/queue.sql new file mode 100644 index 00000000..9fbd49be --- /dev/null +++ b/stovepipe/extension/storage/mysql/schema/queue.sql @@ -0,0 +1,10 @@ +-- queue holds per-queue coordination state for the validation pipeline: the last-green +-- bookmark, in-flight gate count, and latest-request sequence pointer. +CREATE TABLE IF NOT EXISTS queue ( + name VARCHAR(255) NOT NULL, + last_green_uri VARCHAR(255) NOT NULL DEFAULT '', + in_flight_count INT NOT NULL DEFAULT 0, + latest_request_seq BIGINT NOT NULL DEFAULT 0, + version INT NOT NULL, + PRIMARY KEY (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/stovepipe/extension/storage/mysql/storage.go b/stovepipe/extension/storage/mysql/storage.go index 93b04eb7..19f55299 100644 --- a/stovepipe/extension/storage/mysql/storage.go +++ b/stovepipe/extension/storage/mysql/storage.go @@ -19,7 +19,6 @@ import ( _ "github.com/go-sql-driver/mysql" "github.com/uber-go/tally" - "github.com/uber/submitqueue/stovepipe/extension/storage" ) @@ -27,6 +26,7 @@ type mysqlStorage struct { db *sql.DB requestStore storage.RequestStore requestURIStore storage.RequestURIStore + queueStore storage.QueueStore } // NewStorage creates a new MySQL-backed storage. @@ -35,6 +35,7 @@ func NewStorage(db *sql.DB, scope tally.Scope) (storage.Storage, error) { db: db, requestStore: NewRequestStore(db, scope.SubScope("request_store")), requestURIStore: NewRequestURIStore(db, scope.SubScope("request_uri_store")), + queueStore: NewQueueStore(db, scope.SubScope("queue_store")), }, nil } @@ -48,6 +49,11 @@ func (f *mysqlStorage) GetRequestURIStore() storage.RequestURIStore { return f.requestURIStore } +// GetQueueStore returns the MySQL-backed QueueStore. +func (f *mysqlStorage) GetQueueStore() storage.QueueStore { + return f.queueStore +} + // Close closes the underlying database connection. func (f *mysqlStorage) Close() error { return f.db.Close() diff --git a/stovepipe/extension/storage/queue_store.go b/stovepipe/extension/storage/queue_store.go new file mode 100644 index 00000000..ac12825e --- /dev/null +++ b/stovepipe/extension/storage/queue_store.go @@ -0,0 +1,43 @@ +// 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=queue_store.go -destination=mock/queue_store_mock.go -package=mock + +import ( + "context" + + "github.com/uber/submitqueue/stovepipe/entity" +) + +// QueueStore persists per-queue coordination rows, keyed by queue name. +type QueueStore interface { + // GetOrCreate returns the queue row for name, creating it with defaults if absent. + // defaults supplies initial field values for a new row (Name is set from name). + // On a create race, re-reads and returns the canonical row. + GetOrCreate(ctx context.Context, name string, defaults entity.Queue) (entity.Queue, error) + + // Get retrieves a queue by name. Returns ErrNotFound if the queue is not found. + Get(ctx context.Context, name string) (entity.Queue, error) + + // Update persists the mutable fields of queue if the stored version matches + // oldVersion, writing newVersion. Returns ErrVersionMismatch if the stored + // version does not match (including when the queue does not exist). + // + // Version arithmetic is owned by the caller: it computes newVersion (typically + // oldVersion+1) and only assigns queue.Version = newVersion after this call + // succeeds. The store performs a pure conditional write. + Update(ctx context.Context, queue entity.Queue, oldVersion, newVersion int32) error +} diff --git a/stovepipe/extension/storage/storage.go b/stovepipe/extension/storage/storage.go index d42406bb..0d16ea3b 100644 --- a/stovepipe/extension/storage/storage.go +++ b/stovepipe/extension/storage/storage.go @@ -50,6 +50,9 @@ type Storage interface { // GetRequestURIStore returns the RequestURIStore instance. GetRequestURIStore() RequestURIStore + // GetQueueStore returns the QueueStore instance. + GetQueueStore() QueueStore + // 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/BUILD.bazel b/test/integration/stovepipe/extension/storage/BUILD.bazel new file mode 100644 index 00000000..b1f4654b --- /dev/null +++ b/test/integration/stovepipe/extension/storage/BUILD.bazel @@ -0,0 +1,16 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["suite.go"], + importpath = "github.com/uber/submitqueue/test/integration/stovepipe/extension/storage", + visibility = ["//visibility:public"], + deps = [ + "//stovepipe/entity:go_default_library", + "//stovepipe/extension/storage:go_default_library", + "//test/testutil:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + "@com_github_stretchr_testify//suite:go_default_library", + ], +) diff --git a/test/integration/stovepipe/extension/storage/mysql/BUILD.bazel b/test/integration/stovepipe/extension/storage/mysql/BUILD.bazel index ba1e9e8c..7125598c 100644 --- a/test/integration/stovepipe/extension/storage/mysql/BUILD.bazel +++ b/test/integration/stovepipe/extension/storage/mysql/BUILD.bazel @@ -15,6 +15,7 @@ go_test( "//stovepipe/entity:go_default_library", "//stovepipe/extension/storage:go_default_library", "//stovepipe/extension/storage/mysql:go_default_library", + "//test/integration/stovepipe/extension/storage:go_default_library", "//test/testutil:go_default_library", "@com_github_go_sql_driver_mysql//:go_default_library", "@com_github_stretchr_testify//require:go_default_library", diff --git a/test/integration/stovepipe/extension/storage/mysql/storage_test.go b/test/integration/stovepipe/extension/storage/mysql/storage_test.go index 1efdf7f7..c42b06e8 100644 --- a/test/integration/stovepipe/extension/storage/mysql/storage_test.go +++ b/test/integration/stovepipe/extension/storage/mysql/storage_test.go @@ -23,10 +23,10 @@ import ( "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/uber-go/tally" - "github.com/uber/submitqueue/stovepipe/entity" "github.com/uber/submitqueue/stovepipe/extension/storage" mysqlstorage "github.com/uber/submitqueue/stovepipe/extension/storage/mysql" + storagesuite "github.com/uber/submitqueue/test/integration/stovepipe/extension/storage" "github.com/uber/submitqueue/test/testutil" ) @@ -186,3 +186,51 @@ func (s *MySQLRequestStoreSuite) TestURIMappingDistinctAcrossQueues() { require.NoError(s.T(), err) require.Equal(s.T(), "request/queue-b/1", idB) } + +// MySQLQueueStoreSuite exercises the MySQL-backed QueueStore by embedding the shared contract suite. +type MySQLQueueStoreSuite struct { + storagesuite.QueueStoreContractSuite + stack *testutil.ComposeStack + db *sql.DB + log *testutil.TestLogger +} + +func TestMySQLQueueStore(t *testing.T) { + suite.Run(t, new(MySQLQueueStoreSuite)) +} + +func (s *MySQLQueueStoreSuite) 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.SetQueueStore(store.GetQueueStore()) + 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 new file mode 100644 index 00000000..7c49c5ab --- /dev/null +++ b/test/integration/stovepipe/extension/storage/suite.go @@ -0,0 +1,227 @@ +// 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 + +import ( + "context" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/storage" + "github.com/uber/submitqueue/test/testutil" +) + +// QueueStoreContractSuite defines contract tests for storage.QueueStore. +// All QueueStore implementations must pass these tests. +type QueueStoreContractSuite struct { + suite.Suite + ctx context.Context + queueStore storage.QueueStore + log *testutil.TestLogger +} + +// SetContext sets the context for tests. +func (s *QueueStoreContractSuite) SetContext(ctx context.Context) { + s.ctx = ctx +} + +// SetQueueStore provides the concrete QueueStore under test. +func (s *QueueStoreContractSuite) SetQueueStore(store storage.QueueStore) { + s.queueStore = store +} + +// SetLogger sets the logger for tests. +func (s *QueueStoreContractSuite) SetLogger(log *testutil.TestLogger) { + s.log = log +} + +func (s *QueueStoreContractSuite) queueDefaults() entity.Queue { + return entity.Queue{Version: 1} +} + +// TestQueueStore_GetOrCreateCreates verifies GetOrCreate inserts a new row with zero-value runtime fields. +func (s *QueueStoreContractSuite) TestQueueStore_GetOrCreateCreates() { + t := s.T() + const name = "contract/create" + + got, err := s.queueStore.GetOrCreate(s.ctx, name, s.queueDefaults()) + require.NoError(t, err) + assert.Equal(t, entity.Queue{ + Name: name, + LatestRequestSeq: 0, + Version: 1, + }, got) + + s.log.Logf("GetOrCreateCreates passed: created queue %s", name) +} + +// TestQueueStore_GetOrCreateWithDefaults verifies caller-supplied defaults are persisted on create. +func (s *QueueStoreContractSuite) TestQueueStore_GetOrCreateWithDefaults() { + t := s.T() + const name = "contract/defaults" + + defaults := entity.Queue{ + LastGreenURI: "git://remote/monorepo/main/green-bbbb", + LatestRequestSeq: 99, + Version: 1, + } + + got, err := s.queueStore.GetOrCreate(s.ctx, name, defaults) + require.NoError(t, err) + assert.Equal(t, entity.Queue{ + Name: name, + LastGreenURI: defaults.LastGreenURI, + LatestRequestSeq: defaults.LatestRequestSeq, + Version: 1, + }, got) + + s.log.Logf("GetOrCreateWithDefaults passed: persisted defaults for queue %s", name) +} + +// TestQueueStore_GetOrCreateIdempotent verifies a second GetOrCreate returns the existing row unchanged. +func (s *QueueStoreContractSuite) TestQueueStore_GetOrCreateIdempotent() { + t := s.T() + const name = "contract/idempotent" + + first, err := s.queueStore.GetOrCreate(s.ctx, name, s.queueDefaults()) + require.NoError(t, err) + + second, err := s.queueStore.GetOrCreate(s.ctx, name, entity.Queue{ + LastGreenURI: "git://remote/monorepo/main/ignored-on-hit", + LatestRequestSeq: 500, + Version: 1, + }) + require.NoError(t, err) + assert.Equal(t, first, second) + + s.log.Logf("GetOrCreateIdempotent passed: queue %s", name) +} + +// TestQueueStore_GetOrCreateDefaultVersion verifies GetOrCreate writes version=1 when defaults.Version is zero. +func (s *QueueStoreContractSuite) TestQueueStore_GetOrCreateDefaultVersion() { + t := s.T() + const name = "contract/default-version" + + got, err := s.queueStore.GetOrCreate(s.ctx, name, entity.Queue{}) + require.NoError(t, err) + assert.Equal(t, int32(1), got.Version) + + s.log.Logf("GetOrCreateDefaultVersion passed: queue %s", name) +} + +// TestQueueStore_GetNotFound verifies Get returns ErrNotFound for a missing queue. +func (s *QueueStoreContractSuite) TestQueueStore_GetNotFound() { + t := s.T() + + _, err := s.queueStore.Get(s.ctx, "contract/does-not-exist") + assert.True(t, storage.IsNotFound(err)) + + s.log.Logf("GetNotFound passed") +} + +// TestQueueStore_UpdateCAS verifies a conditional update persists all mutable fields and rejects stale versions. +func (s *QueueStoreContractSuite) TestQueueStore_UpdateCAS() { + t := s.T() + const name = "contract/update-cas" + + created, err := s.queueStore.GetOrCreate(s.ctx, name, s.queueDefaults()) + require.NoError(t, err) + + updated := created + updated.LastGreenURI = "git://remote/monorepo/main/green-cccc" + updated.LatestRequestSeq = 42 + updated.InFlightCount = 1 + require.NoError(t, s.queueStore.Update(s.ctx, updated, 1, 2)) + + got, err := s.queueStore.Get(s.ctx, name) + require.NoError(t, err) + assert.Equal(t, updated.LastGreenURI, got.LastGreenURI) + assert.Equal(t, int64(42), got.LatestRequestSeq) + assert.Equal(t, int32(1), got.InFlightCount) + assert.Equal(t, int32(2), got.Version) + + err = s.queueStore.Update(s.ctx, updated, 1, 2) + assert.ErrorIs(t, err, storage.ErrVersionMismatch) + + s.log.Logf("UpdateCAS passed: queue %s", name) +} + +// TestQueueStore_UpdateNotFoundIsVersionMismatch verifies Update on a missing row returns ErrVersionMismatch. +func (s *QueueStoreContractSuite) TestQueueStore_UpdateNotFoundIsVersionMismatch() { + t := s.T() + + err := s.queueStore.Update(s.ctx, entity.Queue{Name: "contract/missing"}, 1, 2) + assert.ErrorIs(t, err, storage.ErrVersionMismatch) + + s.log.Logf("UpdateNotFoundIsVersionMismatch passed") +} + +// TestQueueStore_UpdateSequentialCAS verifies successive conditional updates advance version monotonically. +func (s *QueueStoreContractSuite) TestQueueStore_UpdateSequentialCAS() { + t := s.T() + const name = "contract/sequential-cas" + + created, err := s.queueStore.GetOrCreate(s.ctx, name, s.queueDefaults()) + require.NoError(t, err) + require.Equal(t, int32(1), created.Version) + + v2 := created + v2.LatestRequestSeq = 10 + require.NoError(t, s.queueStore.Update(s.ctx, v2, 1, 2)) + + v3 := v2 + v3.Version = 2 + v3.InFlightCount = 1 + require.NoError(t, s.queueStore.Update(s.ctx, v3, 2, 3)) + + got, err := s.queueStore.Get(s.ctx, name) + require.NoError(t, err) + assert.Equal(t, int64(10), got.LatestRequestSeq) + assert.Equal(t, int32(1), got.InFlightCount) + assert.Equal(t, int32(3), got.Version) + + s.log.Logf("UpdateSequentialCAS passed: queue %s", name) +} + +// TestQueueStore_QueueIsolation verifies updates to one queue do not affect another. +func (s *QueueStoreContractSuite) TestQueueStore_QueueIsolation() { + t := s.T() + const ( + nameA = "contract/isolation-a" + nameB = "contract/isolation-b" + ) + + _, err := s.queueStore.GetOrCreate(s.ctx, nameA, s.queueDefaults()) + require.NoError(t, err) + baseline, err := s.queueStore.GetOrCreate(s.ctx, nameB, s.queueDefaults()) + require.NoError(t, err) + + updatedA := entity.Queue{ + Name: nameA, + LastGreenURI: "git://remote/monorepo/a/green", + LatestRequestSeq: 7, + InFlightCount: 2, + Version: 1, + } + require.NoError(t, s.queueStore.Update(s.ctx, updatedA, 1, 2)) + + gotB, err := s.queueStore.Get(s.ctx, nameB) + require.NoError(t, err) + assert.Equal(t, baseline, gotB) + + s.log.Logf("QueueIsolation passed: queues %s and %s", nameA, nameB) +}