Skip to content

Commit 3ea3843

Browse files
committed
feat(speculation): standard composed speculator
Add submitqueue/extension/speculation/speculator/standard, the standard Speculator. It funds the queue's most promising speculation paths first until the build budget is spent: candidates are considered in descending likelihood, in-flight paths stay funded rather than restarting, finished paths are never re-proposed, and everything below the budget cut waits for a later run. Where the ranking and the budget policy come from is swappable — the Generator ranks, the Allocator spends — so pairing bestfirst with sticky yields the default speculation policy, and changing either behavior means swapping a part, not writing a new Speculator. standard's behavior is documented next to the code in standard/README.md; the speculator README keeps only the extension contract.
1 parent 00ca258 commit 3ea3843

5 files changed

Lines changed: 203 additions & 0 deletions

File tree

submitqueue/extension/speculation/speculator/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ The `speculator` package defines the one speculation extension the speculate con
88

99
Like the other extensions, a `Speculator` is selected **per queue** by the wiring layer through the `Config` (queue name) and `Factory` interface. Budget, clock, and any extra data are injected at construction by the integrator, not carried on the contract.
1010

11+
## Implementations
12+
13+
- [`standard`](standard/README.md) — funds the most promising paths first until the build budget is spent, composed from a swappable `Generator` (ranking) and `Allocator` (budget policy).
14+
1115
## Adding a backend
1216

1317
Create a package under `speculator/<backend>/` whose `New(...)` returns a `speculator.Speculator`, injecting whatever it needs at construction. Resolve any content it requires internally; do not add a `Config` or `Factory` implementation here — per-queue routing and the factory adapter live in the wiring layer.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["standard.go"],
6+
importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/speculator/standard",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//submitqueue/entity:go_default_library",
10+
"//submitqueue/extension/speculation/allocator:go_default_library",
11+
"//submitqueue/extension/speculation/generator:go_default_library",
12+
"//submitqueue/extension/speculation/speculator:go_default_library",
13+
],
14+
)
15+
16+
go_test(
17+
name = "go_default_test",
18+
srcs = ["standard_test.go"],
19+
embed = [":go_default_library"],
20+
deps = [
21+
"//submitqueue/entity:go_default_library",
22+
"//submitqueue/extension/speculation/allocator/mock:go_default_library",
23+
"//submitqueue/extension/speculation/allocator/sticky:go_default_library",
24+
"//submitqueue/extension/speculation/generator/bestfirst:go_default_library",
25+
"//submitqueue/extension/speculation/generator/mock:go_default_library",
26+
"@com_github_stretchr_testify//assert:go_default_library",
27+
"@com_github_stretchr_testify//require:go_default_library",
28+
"@org_uber_go_mock//gomock:go_default_library",
29+
],
30+
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# standard
2+
3+
The `standard` `Speculator` funds the queue's most promising speculation paths first, until the build budget is spent.
4+
5+
Each run it considers candidate paths in descending order of how likely they are to be the future that actually happens, and proposes builds down that ranking: paths already pending or building stay funded rather than restarting, paths whose builds already finished are never re-proposed, and new builds fill whatever budget remains. When the budget runs out, everything below the cut simply waits for a later run — a batch's verdict never depends on what was funded, only on how its dependencies resolve and which builds pass.
6+
7+
Where the two halves of that behavior come from is swappable: the ranking is the `Generator`'s (the default `bestfirst` scores each path by the probability its bets all come true), and the budget policy is the `Allocator`'s (the default `sticky` fills only free slots and never preempts a running build; a preempting allocator would cancel a low-value in-flight path to fund a better one). The `standard` Speculator itself decides nothing — it connects the `Generator`'s stream to the `Allocator` — so changing prioritization or budget behavior means swapping one of those parts, not writing a new `Speculator`.
8+
9+
The `Generator`/`Allocator` split is internal to `standard`, not part of the `Speculator` contract: an alternate `Speculator` could compute build and cancel decisions directly.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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 standard provides the standard speculator.Speculator: it composes a
16+
// Generator and an Allocator so a queue's candidate scoring and its budget
17+
// rationing policy can vary independently without changing the Speculator. The
18+
// Speculator itself decides nothing — it opens the Generator's candidate stream
19+
// and hands it to the Allocator; all of its behavior comes from those two parts.
20+
package standard
21+
22+
import (
23+
"context"
24+
25+
"github.com/uber/submitqueue/submitqueue/entity"
26+
"github.com/uber/submitqueue/submitqueue/extension/speculation/allocator"
27+
"github.com/uber/submitqueue/submitqueue/extension/speculation/generator"
28+
"github.com/uber/submitqueue/submitqueue/extension/speculation/speculator"
29+
)
30+
31+
// spec is a speculator.Speculator that opens the generator's candidate stream
32+
// and hands it to the allocator to spend the build budget.
33+
type spec struct {
34+
gen generator.Generator
35+
alloc allocator.Allocator
36+
}
37+
38+
// New returns the standard speculator.Speculator, composing a Generator and an
39+
// Allocator.
40+
func New(gen generator.Generator, alloc allocator.Allocator) speculator.Speculator {
41+
return spec{gen: gen, alloc: alloc}
42+
}
43+
44+
// Speculate opens the generator over the queue's batches, then lets the
45+
// allocator spend the budget over the resulting candidate iterator, reconciling
46+
// it against the path sets.
47+
func (s spec) Speculate(ctx context.Context, batches []entity.Batch, pathSets []entity.SpeculationPathSet) ([]entity.Speculation, error) {
48+
iter, err := s.gen.Open(ctx, batches)
49+
if err != nil {
50+
return nil, err
51+
}
52+
return s.alloc.Allocate(ctx, pathSets, iter)
53+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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 standard
16+
17+
import (
18+
"context"
19+
"errors"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
"go.uber.org/mock/gomock"
25+
26+
"github.com/uber/submitqueue/submitqueue/entity"
27+
allocatormock "github.com/uber/submitqueue/submitqueue/extension/speculation/allocator/mock"
28+
"github.com/uber/submitqueue/submitqueue/extension/speculation/allocator/sticky"
29+
"github.com/uber/submitqueue/submitqueue/extension/speculation/generator/bestfirst"
30+
generatormock "github.com/uber/submitqueue/submitqueue/extension/speculation/generator/mock"
31+
)
32+
33+
// betFor returns the bet a path carries for a given dependency batch.
34+
func betFor(p entity.SpeculationPath, dep string) entity.DependencyBetType {
35+
for _, b := range p.Bets {
36+
if b.Batch == dep {
37+
return b.Bet
38+
}
39+
}
40+
return entity.BetUnknown
41+
}
42+
43+
// constScorer is a minimal scorer.Scorer that scores every batch identically.
44+
type constScorer struct{ v float64 }
45+
46+
func (c constScorer) Score(context.Context, entity.Batch) (float64, error) { return c.v, nil }
47+
48+
func TestComposed_EndToEnd_NaivePair(t *testing.T) {
49+
batches := []entity.Batch{
50+
{ID: "q/1", State: entity.BatchStateSpeculating},
51+
{ID: "q/2", State: entity.BatchStateSpeculating, Dependencies: []string{"q/1"}},
52+
}
53+
54+
// bestfirst generator + sticky allocator with a 2-build budget.
55+
spec := New(bestfirst.New(constScorer{0.9}), sticky.New(2))
56+
got, err := spec.Speculate(context.Background(), batches, nil)
57+
require.NoError(t, err)
58+
59+
// Two free budget slots -> two builds. Everything proposed is a build.
60+
require.Len(t, got, 2)
61+
var q2 *entity.Speculation
62+
for i := range got {
63+
assert.Equal(t, entity.PathActionBuild, got[i].Action)
64+
if got[i].Path.Head == "q/2" {
65+
q2 = &got[i]
66+
}
67+
}
68+
// q/2's funded path is the optimistic all-included guess on q/1.
69+
require.NotNil(t, q2)
70+
assert.Equal(t, entity.BetIncluded, betFor(q2.Path, "q/1"))
71+
}
72+
73+
func TestComposed_WiresGeneratorIntoAllocator(t *testing.T) {
74+
ctrl := gomock.NewController(t)
75+
76+
batches := []entity.Batch{{ID: "q/1", State: entity.BatchStateSpeculating}}
77+
pathSets := []entity.SpeculationPathSet{{Head: "q/1"}}
78+
want := []entity.Speculation{{
79+
Path: entity.SpeculationPath{Head: "q/1"},
80+
Action: entity.PathActionBuild,
81+
}}
82+
83+
iter := generatormock.NewMockPathIterator(ctrl)
84+
gen := generatormock.NewMockGenerator(ctrl)
85+
alloc := allocatormock.NewMockAllocator(ctrl)
86+
87+
gen.EXPECT().Open(gomock.Any(), batches).Return(iter, nil)
88+
alloc.EXPECT().Allocate(gomock.Any(), pathSets, iter).Return(want, nil)
89+
90+
got, err := New(gen, alloc).Speculate(context.Background(), batches, pathSets)
91+
require.NoError(t, err)
92+
assert.Equal(t, want, got)
93+
}
94+
95+
func TestComposed_PropagatesGeneratorError(t *testing.T) {
96+
ctrl := gomock.NewController(t)
97+
98+
errOpen := errors.New("open boom")
99+
gen := generatormock.NewMockGenerator(ctrl)
100+
alloc := allocatormock.NewMockAllocator(ctrl)
101+
102+
gen.EXPECT().Open(gomock.Any(), gomock.Any()).Return(nil, errOpen)
103+
// Allocate must not be called when Open fails (no alloc.EXPECT()).
104+
105+
_, err := New(gen, alloc).Speculate(context.Background(), nil, nil)
106+
require.ErrorIs(t, err, errOpen)
107+
}

0 commit comments

Comments
 (0)