Skip to content

Commit 48de2e7

Browse files
authored
feat(stovepipe): add QueueStore interface, mock, and MySQL implementations (#313)
## Why? Implementing `process` step per the design in https://github.com/uber/submitqueue/blob/main/doc/rfc/stovepipe/steps/process.md ## What? Storage setup for Stovepipe's new Queue entity: - Introduce the per-queue coordination store backing the process stage: GetOrCreate, Get, and Update. - Wire QueueStore into the Storage aggregator and add the queue table schema. - Include MySQL integration tests for create, idempotent get-or-create, CAS updates, and version mismatch handling. ## Test Plan - Integration tests - Next few PRs will begin to incorporate this logic and we can test it locally
1 parent 7435b75 commit 48de2e7

15 files changed

Lines changed: 571 additions & 3 deletions

File tree

doc/rfc/stovepipe/steps/process.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ Transitions use the repo's optimistic-locking pattern: compute `newVersion = old
205205

206206
New key/value-shaped operations (single-key reads/writes, no server-side filtering or aggregation):
207207

208-
- **`QueueStore`** (new): `GetOrCreate(ctx, name, defaults)`, `Get(ctx, name)`, and `Update(ctx, queue, oldVersion, newVersion)` (CAS). Ingest `GetOrCreate`s and CASes `latest_request_seq`; `process` CASes `in_flight_count`; `record` CASes `last_green_uri` + `in_flight_count`.
208+
- **`QueueStore`** (new): `Create(ctx, queue)`, `Get(ctx, name)`, and `Update(ctx, queue, oldVersion, newVersion)` (CAS). Callers orchestrate get-or-create; ingest CASes `latest_request_seq`; `process` CASes `in_flight_count`; `record` CASes `last_green_uri` + `in_flight_count`.
209209
- **`RequestStore`**: no new methods — the added `Request` fields ride the existing `Create`/`Update` CAS.
210210

211211
No "list requests by queue/state" query is introduced; coalescing uses the single-row `latest_request_seq` pointer instead, keeping the contract satisfiable by a plain KV backend.

stovepipe/extension/storage/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ load("@rules_go//go:def.bzl", "go_library")
33
go_library(
44
name = "go_default_library",
55
srcs = [
6+
"queue_store.go",
67
"request_store.go",
78
"request_uri_store.go",
89
"storage.go",

stovepipe/extension/storage/mock/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ load("@rules_go//go:def.bzl", "go_library")
33
go_library(
44
name = "go_default_library",
55
srcs = [
6+
"queue_store_mock.go",
67
"request_store_mock.go",
78
"request_uri_store_mock.go",
89
"storage_mock.go",

stovepipe/extension/storage/mock/queue_store_mock.go

Lines changed: 79 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

stovepipe/extension/storage/mock/storage_mock.go

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

stovepipe/extension/storage/mysql/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ load("@rules_go//go:def.bzl", "go_library")
33
go_library(
44
name = "go_default_library",
55
srcs = [
6+
"queue_store.go",
67
"request_store.go",
78
"request_uri_store.go",
89
"storage.go",
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package mysql
16+
17+
import (
18+
"context"
19+
"database/sql"
20+
"errors"
21+
"fmt"
22+
23+
"github.com/uber-go/tally"
24+
"github.com/uber/submitqueue/platform/metrics"
25+
"github.com/uber/submitqueue/stovepipe/entity"
26+
"github.com/uber/submitqueue/stovepipe/extension/storage"
27+
)
28+
29+
type queueStore struct {
30+
db *sql.DB
31+
scope tally.Scope
32+
}
33+
34+
// NewQueueStore creates a new MySQL-backed QueueStore.
35+
func NewQueueStore(db *sql.DB, scope tally.Scope) storage.QueueStore {
36+
return &queueStore{db: db, scope: scope}
37+
}
38+
39+
// Create persists a new queue row. Returns ErrAlreadyExists if the name already exists.
40+
func (q *queueStore) Create(ctx context.Context, queue entity.Queue) (retErr error) {
41+
op := metrics.Begin(q.scope, "create")
42+
defer func() { op.Complete(retErr) }()
43+
44+
_, err := q.db.ExecContext(ctx,
45+
`INSERT INTO queue (name, last_green_uri, in_flight_count, latest_request_seq, version)
46+
VALUES (?, ?, ?, ?, ?)`,
47+
queue.Name,
48+
queue.LastGreenURI,
49+
queue.InFlightCount,
50+
queue.LatestRequestSeq,
51+
queue.Version,
52+
)
53+
if err != nil {
54+
if isDuplicateEntry(err) {
55+
return fmt.Errorf("queue name=%s: %w", queue.Name, storage.ErrAlreadyExists)
56+
}
57+
return fmt.Errorf("failed to insert queue name=%s: %w", queue.Name, err)
58+
}
59+
return nil
60+
}
61+
62+
// Get retrieves a queue by name. Returns ErrNotFound if the queue is not found.
63+
func (q *queueStore) Get(ctx context.Context, name string) (ret entity.Queue, retErr error) {
64+
op := metrics.Begin(q.scope, "get")
65+
defer func() { op.Complete(retErr) }()
66+
67+
var queue entity.Queue
68+
err := q.db.QueryRowContext(ctx,
69+
"SELECT name, last_green_uri, in_flight_count, latest_request_seq, version FROM queue WHERE name = ?",
70+
name,
71+
).Scan(
72+
&queue.Name,
73+
&queue.LastGreenURI,
74+
&queue.InFlightCount,
75+
&queue.LatestRequestSeq,
76+
&queue.Version,
77+
)
78+
79+
if errors.Is(err, sql.ErrNoRows) {
80+
return entity.Queue{}, storage.WrapNotFound(err)
81+
}
82+
if err != nil {
83+
return entity.Queue{}, fmt.Errorf("failed to get queue name=%s from the database: %w", name, err)
84+
}
85+
86+
return queue, nil
87+
}
88+
89+
// Update persists the mutable fields of queue if the stored version matches oldVersion,
90+
// writing newVersion. Returns ErrVersionMismatch if the stored version does not match.
91+
func (q *queueStore) Update(ctx context.Context, queue entity.Queue, oldVersion, newVersion int32) (retErr error) {
92+
op := metrics.Begin(q.scope, "update")
93+
defer func() { op.Complete(retErr) }()
94+
95+
result, err := q.db.ExecContext(ctx,
96+
`UPDATE queue
97+
SET last_green_uri = ?, in_flight_count = ?, latest_request_seq = ?, version = ?
98+
WHERE name = ? AND version = ?`,
99+
queue.LastGreenURI,
100+
queue.InFlightCount,
101+
queue.LatestRequestSeq,
102+
newVersion,
103+
queue.Name,
104+
oldVersion,
105+
)
106+
if err != nil {
107+
return fmt.Errorf(
108+
"failed to update queue name=%q oldVersion=%d newVersion=%d: %w",
109+
queue.Name, oldVersion, newVersion, err,
110+
)
111+
}
112+
113+
rowsAffected, err := result.RowsAffected()
114+
if err != nil {
115+
return fmt.Errorf(
116+
"failed to get rows affected from update for name=%q oldVersion=%d newVersion=%d: %w",
117+
queue.Name, oldVersion, newVersion, err,
118+
)
119+
}
120+
121+
if rowsAffected != 1 {
122+
return fmt.Errorf(
123+
"version mismatch for queue update: name=%q expected_version=%d: %w",
124+
queue.Name, oldVersion, storage.ErrVersionMismatch,
125+
)
126+
}
127+
128+
return nil
129+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
-- queue holds per-queue coordination state for the validation pipeline: the last-green
2+
-- bookmark, in-flight gate count, and latest-request sequence pointer.
3+
CREATE TABLE IF NOT EXISTS queue (
4+
name VARCHAR(255) NOT NULL,
5+
last_green_uri VARCHAR(255) NOT NULL DEFAULT '',
6+
in_flight_count INT NOT NULL DEFAULT 0,
7+
latest_request_seq BIGINT NOT NULL DEFAULT 0,
8+
version INT NOT NULL,
9+
PRIMARY KEY (name)
10+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

stovepipe/extension/storage/mysql/storage.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ import (
1919

2020
_ "github.com/go-sql-driver/mysql"
2121
"github.com/uber-go/tally"
22-
2322
"github.com/uber/submitqueue/stovepipe/extension/storage"
2423
)
2524

2625
type mysqlStorage struct {
2726
db *sql.DB
2827
requestStore storage.RequestStore
2928
requestURIStore storage.RequestURIStore
29+
queueStore storage.QueueStore
3030
}
3131

3232
// NewStorage creates a new MySQL-backed storage.
@@ -35,6 +35,7 @@ func NewStorage(db *sql.DB, scope tally.Scope) (storage.Storage, error) {
3535
db: db,
3636
requestStore: NewRequestStore(db, scope.SubScope("request_store")),
3737
requestURIStore: NewRequestURIStore(db, scope.SubScope("request_uri_store")),
38+
queueStore: NewQueueStore(db, scope.SubScope("queue_store")),
3839
}, nil
3940
}
4041

@@ -48,6 +49,11 @@ func (f *mysqlStorage) GetRequestURIStore() storage.RequestURIStore {
4849
return f.requestURIStore
4950
}
5051

52+
// GetQueueStore returns the MySQL-backed QueueStore.
53+
func (f *mysqlStorage) GetQueueStore() storage.QueueStore {
54+
return f.queueStore
55+
}
56+
5157
// Close closes the underlying database connection.
5258
func (f *mysqlStorage) Close() error {
5359
return f.db.Close()
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package storage
16+
17+
//go:generate mockgen -source=queue_store.go -destination=mock/queue_store_mock.go -package=mock
18+
19+
import (
20+
"context"
21+
22+
"github.com/uber/submitqueue/stovepipe/entity"
23+
)
24+
25+
// QueueStore persists per-queue coordination rows, keyed by queue name.
26+
type QueueStore interface {
27+
// Create persists a new queue row. queue.Name must be set. Returns ErrAlreadyExists
28+
// if a row with the same name already exists.
29+
Create(ctx context.Context, queue entity.Queue) error
30+
31+
// Get retrieves a queue by name. Returns ErrNotFound if the queue is not found.
32+
Get(ctx context.Context, name string) (entity.Queue, error)
33+
34+
// Update persists the mutable fields of queue if the stored version matches
35+
// oldVersion, writing newVersion. Returns ErrVersionMismatch if the stored
36+
// version does not match (including when the queue does not exist).
37+
//
38+
// Version arithmetic is owned by the caller: it computes newVersion (typically
39+
// oldVersion+1) and only assigns queue.Version = newVersion after this call
40+
// succeeds. The store performs a pure conditional write.
41+
Update(ctx context.Context, queue entity.Queue, oldVersion, newVersion int32) error
42+
}

0 commit comments

Comments
 (0)