Skip to content

Commit 3feafaa

Browse files
committed
feat(storage,entity): speculation path→build mapping store
## Summary ### Why? Integrating speculation needs the controllers to answer "which build belongs to this path" in both directions, without ever looking a row up by non-key attribute — the storage contract stays get/put-by-key so any KV backend can satisfy it. The build system mints its own build identifiers, and those stay the build store's primary key: the runner's ID is the natural name for the build row. What's missing is a durable link between a tree path and its build that exists independent of any in-flight message, hung on the path identity (SpeculationPathInfo.ID) introduced at the bottom of this stack. ### What? `entity.SpeculationPathBuild` is the new mapping entity ({PathID, BuildID, BatchID, CreatedAt, Version}, named after its `speculation_path_build` table) with a `SpeculationPathBuildStore` (Create/Get keyed by PathID): the forward path→build lookup, written only by the build controller, at most one build per path with ErrAlreadyExists making the existing row the truth on races. PathID is globally unique — the path ID contract on SpeculationPathInfo.ID is strengthened accordingly, since this table keys rows by it alone. BatchID makes the row self-describing without parsing PathID's format; Version follows the repo's optimistic-locking convention (write-once today, reserved for future conditional re-pointing flows). `entity.Build` keeps the runner-minted `ID` as its primary key and gains `SpeculationPathID` as a plain column — the reverse build→path lookup; the previously embedded `SpeculationPath` value is dropped as a redundant denormalized copy of what the tree already stores. `SpeculationPath.Equal` (order-sensitive Base + Head) provides structural path identity for the few controller spots where only structure can identify a path (deduplicating enumerator output, carrying entries over across re-enumeration). MySQL gains the `speculation_path_build` table and the build table swaps `speculation_path`/`runner_id` for `speculation_path_id`; schema files are picked up automatically (the schema dir is globbed by both Bazel and the testutil ApplySchema helper). ## Test Plan ✅ `bazel test //submitqueue/entity/... //submitqueue/extension/storage/... //submitqueue/orchestrator/...` and the storage integration suite exercises Create/Get round-trips and the duplicate-create race.
1 parent bce0f4e commit 3feafaa

21 files changed

Lines changed: 509 additions & 85 deletions

submitqueue/entity/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ go_library(
1717
"queue_config.go",
1818
"request.go",
1919
"request_log.go",
20+
"speculation_path_build.go",
2021
"speculation_tree.go",
2122
],
2223
importpath = "github.com/uber/submitqueue/submitqueue/entity",
@@ -36,6 +37,7 @@ go_test(
3637
"land_request_test.go",
3738
"request_log_test.go",
3839
"request_test.go",
40+
"speculation_tree_test.go",
3941
],
4042
embed = [":go_default_library"],
4143
deps = [

submitqueue/entity/build.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,18 @@ func (s BuildStatus) IsTerminal() bool {
5656
// Build represents a build scheduled for a batch along a specific speculation path.
5757
// All fields except the Status are immutable after creation.
5858
type Build struct {
59-
// ID represents the build ID. It is the responsibility of a build management system to ensure
60-
// that this is unique.
59+
// ID is the identifier minted by the queue's build runner when the build
60+
// is triggered; this is the primary storage key.
6161
ID string
6262
// BatchID is the batch for which this build is scheduled.
6363
BatchID string
64-
// SpeculationPath is the speculation path that represents this build. For
65-
// a given batch this path is crafted from the graph that is generated from the
66-
// dependencies of this batch. Its Head is the batch being verified (equal to
67-
// BatchID) and its Base is the assumed-good prefix of predecessor batches.
68-
SpeculationPath SpeculationPath
64+
// SpeculationPathID is the ID of the speculation-tree path this build
65+
// verifies (SpeculationPathInfo.ID). The path's structure (Base/Head) is
66+
// not embedded here — it lives on the tree entry and is looked up via the
67+
// tree (SpeculationPathInfo.Path). This field enables the reverse lookup
68+
// from a build row to its path; the forward lookup (path->build) lives in
69+
// the separate SpeculationPathBuild mapping (see speculation_path_build.go).
70+
SpeculationPathID string
6971
// Status represents the state of the build lifecycle this build is in.
7072
Status BuildStatus
7173
}

submitqueue/entity/build_test.go

Lines changed: 20 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,10 @@ func TestBuildStatus_IsTerminal(t *testing.T) {
6868

6969
func TestBuild_ToBytes(t *testing.T) {
7070
build := Build{
71-
ID: "build-1",
72-
BatchID: "batch-1",
73-
SpeculationPath: SpeculationPath{
74-
Base: []string{"batch-0", "batch-prev"},
75-
Head: "batch-1",
76-
},
77-
Status: BuildStatusAccepted,
71+
ID: "build-1",
72+
BatchID: "batch-1",
73+
Status: BuildStatusAccepted,
74+
SpeculationPathID: "path-1",
7875
}
7976

8077
data, err := build.ToBytes()
@@ -86,17 +83,15 @@ func TestBuild_ToBytes(t *testing.T) {
8683
assert.Contains(t, jsonStr, "build-1")
8784
assert.Contains(t, jsonStr, "batch-1")
8885
assert.Contains(t, jsonStr, "accepted")
86+
assert.Contains(t, jsonStr, "path-1")
8987
}
9088

9189
func TestBuildFromBytes(t *testing.T) {
9290
original := Build{
93-
ID: "build-42",
94-
BatchID: "batch-7",
95-
SpeculationPath: SpeculationPath{
96-
Base: []string{"batch-5", "batch-6"},
97-
Head: "batch-7",
98-
},
99-
Status: BuildStatusAccepted,
91+
ID: "build-42",
92+
BatchID: "batch-7",
93+
Status: BuildStatusAccepted,
94+
SpeculationPathID: "path-42",
10095
}
10196

10297
// Serialize
@@ -110,8 +105,8 @@ func TestBuildFromBytes(t *testing.T) {
110105
// Verify all fields match
111106
assert.Equal(t, original.ID, deserialized.ID)
112107
assert.Equal(t, original.BatchID, deserialized.BatchID)
113-
assert.Equal(t, original.SpeculationPath.Base, deserialized.SpeculationPath.Base)
114108
assert.Equal(t, original.Status, deserialized.Status)
109+
assert.Equal(t, original.SpeculationPathID, deserialized.SpeculationPathID)
115110
}
116111

117112
func TestBuildFromBytes_InvalidJSON(t *testing.T) {
@@ -139,19 +134,16 @@ func TestBuild_SerializationRoundTrip(t *testing.T) {
139134
build Build
140135
}{
141136
{
142-
name: "accepted build with speculation path",
137+
name: "accepted build with speculation path id",
143138
build: Build{
144-
ID: "build-100",
145-
BatchID: "batch-50",
146-
SpeculationPath: SpeculationPath{
147-
Base: []string{"batch-48", "batch-49"},
148-
Head: "batch-50",
149-
},
150-
Status: BuildStatusAccepted,
139+
ID: "build-100",
140+
BatchID: "batch-50",
141+
Status: BuildStatusAccepted,
142+
SpeculationPathID: "path-100",
151143
},
152144
},
153145
{
154-
name: "succeeded build with no speculation base",
146+
name: "succeeded build with no speculation path id",
155147
build: Build{
156148
ID: "build-200",
157149
BatchID: "batch-60",
@@ -161,13 +153,10 @@ func TestBuild_SerializationRoundTrip(t *testing.T) {
161153
{
162154
name: "failed build",
163155
build: Build{
164-
ID: "build-300",
165-
BatchID: "batch-70",
166-
SpeculationPath: SpeculationPath{
167-
Base: []string{"batch-65"},
168-
Head: "batch-70",
169-
},
170-
Status: BuildStatusFailed,
156+
ID: "build-300",
157+
BatchID: "batch-70",
158+
Status: BuildStatusFailed,
159+
SpeculationPathID: "path-300",
171160
},
172161
},
173162
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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 entity
16+
17+
// SpeculationPathBuild is the path->build mapping: given a speculation path's
18+
// ID, it records the build that path resolved to. This is the forward lookup
19+
// (path->build); the reverse lookup (build->path) is Build.SpeculationPathID.
20+
type SpeculationPathBuild struct {
21+
// PathID is the speculation path's ID (SpeculationPathInfo.ID). It is the
22+
// primary key of this mapping and is globally unique — see
23+
// SpeculationPathInfo.ID for the uniqueness contract — so the mapping
24+
// needs no additional scoping key.
25+
PathID string
26+
// BuildID is the runner-minted build ID (Build.ID) this path resolved to.
27+
BuildID string
28+
// BatchID is the batch whose speculation tree contains this path. It
29+
// makes the row self-describing without parsing PathID's internal format.
30+
BatchID string
31+
// CreatedAt is the creation time of this mapping, in milliseconds since
32+
// epoch.
33+
CreatedAt int64
34+
// Version is the version of the object. It is used for optimistic locking:
35+
// updates are conditional on the persisted version matching the caller's
36+
// expected version. Versioning starts at 1; version arithmetic is owned by
37+
// the controller, the store performs a pure conditional write.
38+
Version int32
39+
}

submitqueue/entity/speculation_tree.go

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
package entity
1616

17+
import "slices"
18+
1719
// SpeculationPath is a single speculation path: an assumed-good prefix of
1820
// predecessor batches (Base) on top of which the batch under verification
1921
// (Head) is built and validated.
@@ -29,6 +31,16 @@ type SpeculationPath struct {
2931
Head string
3032
}
3133

34+
// Equal reports whether p and other are structurally the same speculation
35+
// path. It is true iff Head matches and Base has the same elements in the same
36+
// order — Base order is the build order and is significant. The controller uses
37+
// it where only structure can identify a path (deduplicating enumerator output,
38+
// carrying entries over across re-enumeration); everything else references a
39+
// persisted path by its assigned ID (SpeculationPathInfo.ID).
40+
func (p SpeculationPath) Equal(other SpeculationPath) bool {
41+
return p.Head == other.Head && slices.Equal(p.Base, other.Base)
42+
}
43+
3244
// SpeculationPathStatus is the observed lifecycle state of a speculation path.
3345
// It is written only by the orchestrator's speculate controller (into the
3446
// speculation tree store) and read by the decision seams (selector, prioritizer)
@@ -102,12 +114,15 @@ const (
102114
// entry is persisted; Score, Status, and BuildID are updateable, written only
103115
// by the controller under the tree's Version optimistic lock.
104116
type SpeculationPathInfo struct {
105-
// ID identifies this path within its tree. It is assigned by the controller
106-
// when the path entry is first persisted, immutable thereafter, and unique
107-
// within the tree; its format is the controller's choice and carries no
108-
// meaning — never parse it. Everything outside the tree names a path by this
109-
// ID: seam outputs (path scores, path decisions) and durable links from
110-
// other entities all refer to it rather than restating the Base/Head split.
117+
// ID identifies this path. It is assigned by the controller when the path
118+
// entry is first persisted, immutable thereafter, and globally unique —
119+
// not merely unique within its tree, because other entities key rows by it
120+
// alone (SpeculationPathBuild.PathID is a primary key with no extra
121+
// scoping column). Its format is the controller's choice and carries no
122+
// meaning — never parse it. Everything outside the tree names a path by
123+
// this ID: seam outputs (path scores, path decisions) and durable links
124+
// from other entities all refer to it rather than restating the Base/Head
125+
// split.
111126
ID string
112127
// Path is the Base/Head split this entry covers. Immutable: it identifies
113128
// the entry and never changes after the path is first persisted.
@@ -124,9 +139,9 @@ type SpeculationPathInfo struct {
124139
// only by the controller; read by the decision seams (scorer, selector,
125140
// prioritizer).
126141
Status SpeculationPathStatus
127-
// BuildID links this path to its build. Updateable: empty until a build
128-
// signal confirms the build and the controller records it (Prioritized ->
129-
// Building); the controller never knows the ID at send time.
142+
// BuildID holds the runner-minted build identifier (also the build store's
143+
// primary key) for this path. Updateable: it is empty until the speculate
144+
// controller's reconcile stamps it once a build exists for this path.
130145
BuildID string
131146
}
132147

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 entity
16+
17+
import (
18+
"testing"
19+
20+
"github.com/stretchr/testify/assert"
21+
)
22+
23+
func TestSpeculationPath_Equal(t *testing.T) {
24+
tests := []struct {
25+
name string
26+
path SpeculationPath
27+
other SpeculationPath
28+
equal bool
29+
}{
30+
{
31+
name: "equal paths",
32+
path: SpeculationPath{Base: []string{"q/batch/1", "q/batch/2"}, Head: "q/batch/3"},
33+
other: SpeculationPath{Base: []string{"q/batch/1", "q/batch/2"}, Head: "q/batch/3"},
34+
equal: true,
35+
},
36+
{
37+
name: "different head",
38+
path: SpeculationPath{Base: []string{"q/batch/1"}, Head: "q/batch/2"},
39+
other: SpeculationPath{Base: []string{"q/batch/1"}, Head: "q/batch/3"},
40+
equal: false,
41+
},
42+
{
43+
name: "different base order",
44+
path: SpeculationPath{Base: []string{"q/batch/1", "q/batch/2"}, Head: "q/batch/3"},
45+
other: SpeculationPath{Base: []string{"q/batch/2", "q/batch/1"}, Head: "q/batch/3"},
46+
equal: false,
47+
},
48+
{
49+
name: "different base length",
50+
path: SpeculationPath{Base: []string{"q/batch/1"}, Head: "q/batch/3"},
51+
other: SpeculationPath{Base: []string{"q/batch/1", "q/batch/2"}, Head: "q/batch/3"},
52+
equal: false,
53+
},
54+
{
55+
name: "both empty",
56+
path: SpeculationPath{},
57+
other: SpeculationPath{},
58+
equal: true,
59+
},
60+
{
61+
name: "nil base equals empty base",
62+
path: SpeculationPath{Base: nil, Head: "q/batch/1"},
63+
other: SpeculationPath{Base: []string{}, Head: "q/batch/1"},
64+
equal: true,
65+
},
66+
}
67+
68+
for _, tt := range tests {
69+
t.Run(tt.name, func(t *testing.T) {
70+
assert.Equal(t, tt.equal, tt.path.Equal(tt.other))
71+
// Equal must be symmetric.
72+
assert.Equal(t, tt.equal, tt.other.Equal(tt.path))
73+
})
74+
}
75+
}

submitqueue/extension/storage/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ go_library(
99
"change_store.go",
1010
"request_log_store.go",
1111
"request_store.go",
12+
"speculation_path_build_store.go",
1213
"speculation_tree_store.go",
1314
"storage.go",
1415
],

submitqueue/extension/storage/mock/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ go_library(
99
"change_store_mock.go",
1010
"request_log_store_mock.go",
1111
"request_store_mock.go",
12+
"speculation_path_build_store_mock.go",
1213
"speculation_tree_store_mock.go",
1314
"storage_mock.go",
1415
],

0 commit comments

Comments
 (0)