Skip to content

Commit e481adf

Browse files
committed
feat(storage): speculation path set store
## Summary ### Why? The speculate run reads every head's path set at the start of a run and persists what it funds at the end. `entity.SpeculationPathSet` exists, but nothing can store one. ### What? Adds `storage.SpeculationPathSetStore`, keyed on the head batch ID, with a MySQL implementation, schema, mocks, and contract tests. The mutation is a conditional whole-item put — `Update` replaces the set, guarded on version — because a conditional put on a key is the primitive every backend offers directly; a field-level update is the one shape non-SQL backends would have to emulate with a read-modify-write. Version arguments are explicit and the entity's own `Version` field is ignored, per the storage README. No secondary index: callers that want a queue's live sets enumerate the heads from the batch listing they already hold and read each set by key. ## Test Plan ✅ `bazel test //submitqueue/extension/storage/...` — sqlmock coverage of get/create/update and every error contract, plus a pin that `Update` ignores the entity's `Version` field. ✅ `bazel test //test/integration/submitqueue/extension/storage/mysql:go_default_test` — against real MySQL: round-trip (each stored ID still hashes its stored path), missing head, duplicate create, and a compare-and-swap race where the loser must not restore the path the winner dropped. ✅ `make fmt`, `make gazelle`, `make mocks`
1 parent 7de9d1f commit e481adf

12 files changed

Lines changed: 747 additions & 22 deletions

File tree

submitqueue/extension/storage/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ go_library(
1313
"request_store.go",
1414
"request_summary_store.go",
1515
"request_uri_store.go",
16+
"speculation_path_set_store.go",
1617
"storage.go",
1718
],
1819
importpath = "github.com/uber/submitqueue/submitqueue/extension/storage",

submitqueue/extension/storage/mock/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ go_library(
1313
"request_store_mock.go",
1414
"request_summary_store_mock.go",
1515
"request_uri_store_mock.go",
16+
"speculation_path_set_store_mock.go",
1617
"storage_mock.go",
1718
],
1819
importpath = "github.com/uber/submitqueue/submitqueue/extension/storage/mock",

submitqueue/extension/storage/mock/speculation_path_set_store_mock.go

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

submitqueue/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.

submitqueue/extension/storage/mysql/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ go_library(
1313
"request_store.go",
1414
"request_summary_store.go",
1515
"request_uri_store.go",
16+
"speculation_path_set_store.go",
1617
"storage.go",
1718
],
1819
importpath = "github.com/uber/submitqueue/submitqueue/extension/storage/mysql",
@@ -39,6 +40,7 @@ go_test(
3940
"request_store_test.go",
4041
"request_summary_store_test.go",
4142
"request_uri_store_test.go",
43+
"speculation_path_set_store_test.go",
4244
"storage_test.go",
4345
],
4446
embed = [":go_default_library"],
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
CREATE TABLE IF NOT EXISTS speculation_path_set (
2+
head VARCHAR(255) NOT NULL,
3+
paths JSON NOT NULL,
4+
version INT NOT NULL,
5+
PRIMARY KEY (head)
6+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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+
"encoding/json"
21+
"errors"
22+
"fmt"
23+
24+
"github.com/go-sql-driver/mysql"
25+
"github.com/uber-go/tally"
26+
27+
"github.com/uber/submitqueue/platform/metrics"
28+
"github.com/uber/submitqueue/submitqueue/entity"
29+
"github.com/uber/submitqueue/submitqueue/extension/storage"
30+
)
31+
32+
type speculationPathSetStore struct {
33+
db *sql.DB
34+
scope tally.Scope
35+
}
36+
37+
// NewSpeculationPathSetStore creates a new MySQL-backed SpeculationPathSetStore.
38+
func NewSpeculationPathSetStore(db *sql.DB, scope tally.Scope) storage.SpeculationPathSetStore {
39+
return &speculationPathSetStore{db: db, scope: scope}
40+
}
41+
42+
// Get retrieves a head's path set, where head is the head batch's ID.
43+
// Returns ErrNotFound if the head has no set.
44+
func (s *speculationPathSetStore) Get(ctx context.Context, head string) (ret entity.SpeculationPathSet, retErr error) {
45+
op := metrics.Begin(s.scope, "get", metrics.StorageLatencyBuckets)
46+
defer func() { op.Complete(retErr) }()
47+
48+
var set entity.SpeculationPathSet
49+
var pathsJSON []byte
50+
51+
err := s.db.QueryRowContext(ctx,
52+
"SELECT head, paths, version FROM speculation_path_set WHERE head = ?",
53+
head,
54+
).Scan(&set.Head, &pathsJSON, &set.Version)
55+
56+
if errors.Is(err, sql.ErrNoRows) {
57+
return entity.SpeculationPathSet{}, storage.WrapNotFound(err)
58+
}
59+
if err != nil {
60+
return entity.SpeculationPathSet{}, fmt.Errorf("failed to get speculation path set entity head=%s from the database: %w", head, err)
61+
}
62+
63+
if err := json.Unmarshal(pathsJSON, &set.Paths); err != nil {
64+
return entity.SpeculationPathSet{}, fmt.Errorf("failed to unmarshal paths for speculation path set entity head=%s from the database: %w", head, err)
65+
}
66+
67+
return set, nil
68+
}
69+
70+
// Create stores a head's first path set. Returns ErrAlreadyExists if the head already has one.
71+
func (s *speculationPathSetStore) Create(ctx context.Context, set entity.SpeculationPathSet) (retErr error) {
72+
op := metrics.Begin(s.scope, "create", metrics.StorageLatencyBuckets)
73+
defer func() { op.Complete(retErr) }()
74+
75+
pathsJSON, err := json.Marshal(set.Paths)
76+
if err != nil {
77+
return fmt.Errorf("failed to marshal paths head=%s for Create speculation path set entity: %w", set.Head, err)
78+
}
79+
80+
_, err = s.db.ExecContext(ctx,
81+
"INSERT INTO speculation_path_set (head, paths, version) VALUES (?, ?, ?)",
82+
set.Head, pathsJSON, set.Version,
83+
)
84+
if err != nil {
85+
var mysqlErr *mysql.MySQLError
86+
if errors.As(err, &mysqlErr) && mysqlErr.Number == mysqlErrDuplicateEntry {
87+
return fmt.Errorf("speculation path set entity head=%s: %w", set.Head, storage.ErrAlreadyExists)
88+
}
89+
return fmt.Errorf("failed to insert speculation path set entity head=%s: %w", set.Head, err)
90+
}
91+
92+
return nil
93+
}
94+
95+
// Update replaces the stored set and writes newVersion if the persisted version matches
96+
// oldVersion. If versions do not match, returns ErrVersionMismatch. set.Version is ignored:
97+
// version arithmetic is owned by the caller and this is a pure conditional write.
98+
func (s *speculationPathSetStore) Update(ctx context.Context, set entity.SpeculationPathSet, oldVersion, newVersion int32) (retErr error) {
99+
op := metrics.Begin(s.scope, "update", metrics.StorageLatencyBuckets)
100+
defer func() { op.Complete(retErr) }()
101+
102+
pathsJSON, err := json.Marshal(set.Paths)
103+
if err != nil {
104+
return fmt.Errorf("failed to marshal paths head=%s for Update speculation path set entity: %w", set.Head, err)
105+
}
106+
107+
result, err := s.db.ExecContext(ctx,
108+
"UPDATE speculation_path_set SET paths = ?, version = ? WHERE head = ? AND version = ?",
109+
pathsJSON, newVersion, set.Head, oldVersion,
110+
)
111+
if err != nil {
112+
return fmt.Errorf(
113+
"failed to update speculation path set for head=%q oldVersion=%d newVersion=%d: %w",
114+
set.Head, oldVersion, newVersion, err,
115+
)
116+
}
117+
118+
rowsAffected, err := result.RowsAffected()
119+
if err != nil {
120+
return fmt.Errorf(
121+
"failed to get rows affected from update for head=%q oldVersion=%d newVersion=%d: %w",
122+
set.Head, oldVersion, newVersion, err,
123+
)
124+
}
125+
126+
if rowsAffected != 1 {
127+
return fmt.Errorf(
128+
"version mismatch for speculation path set update: head=%q expected_version=%d: %w",
129+
set.Head, oldVersion, storage.ErrVersionMismatch,
130+
)
131+
}
132+
133+
return nil
134+
}

0 commit comments

Comments
 (0)