Skip to content

Commit b095758

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, Version, CreatedAt}, 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. 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). `entity.QueueID` with ToBytes/QueueIDFromBytes mirrors the BatchID payload pattern for queue-scoped stages. 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 b095758

23 files changed

Lines changed: 625 additions & 79 deletions

submitqueue/entity/BUILD.bazel

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ go_library(
1414
"land_request.go",
1515
"merge_result.go",
1616
"push_result.go",
17+
"queue.go",
1718
"queue_config.go",
1819
"request.go",
1920
"request_log.go",
21+
"speculation_path_build.go",
2022
"speculation_tree.go",
2123
],
2224
importpath = "github.com/uber/submitqueue/submitqueue/entity",
@@ -34,8 +36,10 @@ go_test(
3436
"build_test.go",
3537
"cancel_request_test.go",
3638
"land_request_test.go",
39+
"queue_test.go",
3740
"request_log_test.go",
3841
"request_test.go",
42+
"speculation_tree_test.go",
3943
],
4044
embed = [":go_default_library"],
4145
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
}

submitqueue/entity/queue.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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 "encoding/json"
18+
19+
// QueueID is the queue-message payload for queue-scoped pipeline stages. It
20+
// carries only the queue name; consumers resolve the state they need from
21+
// storage.
22+
type QueueID struct {
23+
// Name is the merge-queue name the message targets.
24+
Name string `json:"name"`
25+
}
26+
27+
// ToBytes serializes the QueueID to JSON bytes for queue message payload.
28+
func (q QueueID) ToBytes() ([]byte, error) {
29+
return json.Marshal(q)
30+
}
31+
32+
// QueueIDFromBytes deserializes a QueueID from JSON bytes.
33+
func QueueIDFromBytes(data []byte) (QueueID, error) {
34+
var qid QueueID
35+
err := json.Unmarshal(data, &qid)
36+
return qid, err
37+
}

submitqueue/entity/queue_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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+
"github.com/stretchr/testify/require"
22+
)
23+
24+
func TestQueueID_SerializationRoundTrip(t *testing.T) {
25+
tests := []struct {
26+
name string
27+
queueID QueueID
28+
}{
29+
{
30+
name: "simple queue name",
31+
queueID: QueueID{Name: "queueA"},
32+
},
33+
{
34+
name: "another queue name",
35+
queueID: QueueID{Name: "queueB"},
36+
},
37+
}
38+
39+
for _, tt := range tests {
40+
t.Run(tt.name, func(t *testing.T) {
41+
data, err := tt.queueID.ToBytes()
42+
require.NoError(t, err)
43+
44+
deserialized, err := QueueIDFromBytes(data)
45+
require.NoError(t, err)
46+
47+
assert.Equal(t, tt.queueID, deserialized)
48+
})
49+
}
50+
}
51+
52+
func TestQueueIDFromBytes_InvalidJSON(t *testing.T) {
53+
_, err := QueueIDFromBytes([]byte(`{"invalid": json"}`))
54+
assert.Error(t, err)
55+
}
56+
57+
func TestQueueIDFromBytes_EmptyJSON(t *testing.T) {
58+
queueID, err := QueueIDFromBytes([]byte(`{}`))
59+
require.NoError(t, err)
60+
61+
assert.Empty(t, queueID.Name)
62+
}
63+
64+
func TestQueueIDFromBytes_EmptyBytes(t *testing.T) {
65+
_, err := QueueIDFromBytes([]byte{})
66+
assert.Error(t, err)
67+
}
68+
69+
func TestQueueIDFromBytes_NilBytes(t *testing.T) {
70+
_, err := QueueIDFromBytes(nil)
71+
assert.Error(t, err)
72+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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. It is written by the build
19+
// controller when it triggers a build for a speculation path; this is the
20+
// forward lookup (path->build), while the reverse lookup (build->path) is
21+
// Build.SpeculationPathID. Today a row is write-once — only Create and Get
22+
// exist, no Update — so Version is always 1 on every row; the field is
23+
// reserved for a future repair/re-trigger flow that conditionally re-points a
24+
// path to a newer build without a schema migration (not used yet).
25+
type SpeculationPathBuild struct {
26+
// PathID is the speculation path's ID (SpeculationPathInfo.ID). It is the
27+
// primary key of this mapping.
28+
PathID string
29+
// BuildID is the runner-minted build ID (Build.ID) this path resolved to.
30+
BuildID string
31+
// BatchID is the batch whose speculation tree contains this path. It
32+
// makes the row self-describing without parsing PathID's internal format.
33+
BatchID string
34+
// Version is the version of the object, 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
37+
// by the controller, the store performs a pure conditional write. Not
38+
// used yet — reserved for a future conditional-update flow.
39+
Version int32
40+
// CreatedAt is the creation time of this mapping, in milliseconds since
41+
// epoch.
42+
CreatedAt int64
43+
}

submitqueue/entity/speculation_tree.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,27 @@ type SpeculationPath struct {
2929
Head string
3030
}
3131

32+
// Equal reports whether p and other are structurally the same speculation
33+
// path. It is true iff Head matches and Base has the same elements in the same
34+
// order — Base order is the build order and is significant. The controller uses
35+
// it where only structure can identify a path (deduplicating enumerator output,
36+
// carrying entries over across re-enumeration); everything else references a
37+
// persisted path by its assigned ID (SpeculationPathInfo.ID).
38+
func (p SpeculationPath) Equal(other SpeculationPath) bool {
39+
if p.Head != other.Head {
40+
return false
41+
}
42+
if len(p.Base) != len(other.Base) {
43+
return false
44+
}
45+
for i := range p.Base {
46+
if p.Base[i] != other.Base[i] {
47+
return false
48+
}
49+
}
50+
return true
51+
}
52+
3253
// SpeculationPathStatus is the observed lifecycle state of a speculation path.
3354
// It is written only by the orchestrator's speculate controller (into the
3455
// speculation tree store) and read by the decision seams (selector, prioritizer)
@@ -108,6 +129,7 @@ type SpeculationPathInfo struct {
108129
// meaning — never parse it. Everything outside the tree names a path by this
109130
// ID: seam outputs (path scores, path decisions) and durable links from
110131
// other entities all refer to it rather than restating the Base/Head split.
132+
// The path→build mapping row is keyed by it (SpeculationPathBuild.PathID).
111133
ID string
112134
// Path is the Base/Head split this entry covers. Immutable: it identifies
113135
// the entry and never changes after the path is first persisted.
@@ -124,9 +146,9 @@ type SpeculationPathInfo struct {
124146
// only by the controller; read by the decision seams (scorer, selector,
125147
// prioritizer).
126148
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.
149+
// BuildID holds the runner-minted build identifier (also the build store's
150+
// primary key) for this path. Updateable: it is empty until the speculate
151+
// controller's reconcile stamps it once a build exists for this path.
130152
BuildID string
131153
}
132154

0 commit comments

Comments
 (0)