Skip to content

Commit e8a2e32

Browse files
committed
feat(speculation): standard composed speculator
Add submitqueue/extension/speculation/speculator/standard, the standard Speculator composed from a Generator and an Allocator. It decides nothing itself — it opens the generator's candidate stream and hands it to the allocator — so its behavior is entirely that of the two parts it is built from. Pairing bestfirst with sticky yields the default speculation policy.
1 parent 6977b91 commit e8a2e32

4 files changed

Lines changed: 197 additions & 0 deletions

File tree

submitqueue/extension/speculation/speculator/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ 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, depth bound, clock, and any extra data are injected at construction by the integrator, not carried on the contract.
1010

11+
## The `standard` Speculator
12+
13+
The `standard` subpackage is a `Speculator` assembled from two swappable composition points — a `Generator` and an `Allocator` (the sibling `generator` and `allocator` packages). The `standard` Speculator decides nothing itself: it opens the `Generator`'s candidate stream and hands it to the `Allocator`. All of its behavior is therefore the behavior of whichever `Generator` and `Allocator` it is built from — the `Generator` chooses which paths are worth considering and in what order, the `Allocator` chooses which of them to fund. Splitting the two lets candidate scoring and budget-rationing policy vary independently.
14+
15+
These composition points are **not** controller-facing extensions: an alternate `Speculator` could compute build and cancel decisions directly, without a `Generator`/`Allocator` split at all. The controller depends only on the `Speculator` contract either way.
16+
1117
## Adding a backend
1218

1319
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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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+
"@org_uber_go_zap//zaptest:go_default_library",
30+
],
31+
)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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 and path sets, then
45+
// lets the allocator spend the budget over the resulting candidate iterator.
46+
func (s spec) Speculate(ctx context.Context, batches []entity.Batch, pathSets []entity.SpeculationPathSet) ([]entity.Speculation, error) {
47+
iter, err := s.gen.Open(ctx, batches, pathSets)
48+
if err != nil {
49+
return nil, err
50+
}
51+
return s.alloc.Allocate(ctx, pathSets, iter)
52+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
"go.uber.org/zap/zaptest"
26+
27+
"github.com/uber/submitqueue/submitqueue/entity"
28+
allocatormock "github.com/uber/submitqueue/submitqueue/extension/speculation/allocator/mock"
29+
"github.com/uber/submitqueue/submitqueue/extension/speculation/allocator/sticky"
30+
"github.com/uber/submitqueue/submitqueue/extension/speculation/generator/bestfirst"
31+
generatormock "github.com/uber/submitqueue/submitqueue/extension/speculation/generator/mock"
32+
)
33+
34+
// betFor returns the bet a path carries for a given dependency batch.
35+
func betFor(p entity.SpeculationPath, dep string) entity.DependencyBetType {
36+
for _, b := range p.Bets {
37+
if b.Batch == dep {
38+
return b.Bet
39+
}
40+
}
41+
return entity.BetUnknown
42+
}
43+
44+
// constScorer is a minimal scorer.Scorer that scores every batch identically.
45+
type constScorer struct{ v float64 }
46+
47+
func (c constScorer) Score(context.Context, entity.Batch) (float64, error) { return c.v, nil }
48+
49+
func TestComposed_EndToEnd_NaivePair(t *testing.T) {
50+
batches := []entity.Batch{
51+
{ID: "q/1", State: entity.BatchStateSpeculating},
52+
{ID: "q/2", State: entity.BatchStateSpeculating, Dependencies: []string{"q/1"}},
53+
}
54+
55+
// bestfirst generator + sticky allocator with a 2-build budget.
56+
spec := New(bestfirst.New(constScorer{0.9}), sticky.New(zaptest.NewLogger(t).Sugar(), 2))
57+
got, err := spec.Speculate(context.Background(), batches, nil)
58+
require.NoError(t, err)
59+
60+
// Two free budget slots -> two builds. Everything proposed is a build.
61+
require.Len(t, got, 2)
62+
var q2 *entity.Speculation
63+
for i := range got {
64+
assert.Equal(t, entity.PathActionBuild, got[i].Action)
65+
if got[i].Path.Head == "q/2" {
66+
q2 = &got[i]
67+
}
68+
}
69+
// q/2's funded path is the optimistic all-included guess on q/1.
70+
require.NotNil(t, q2)
71+
assert.Equal(t, entity.BetIncluded, betFor(q2.Path, "q/1"))
72+
}
73+
74+
func TestComposed_WiresGeneratorIntoAllocator(t *testing.T) {
75+
ctrl := gomock.NewController(t)
76+
77+
batches := []entity.Batch{{ID: "q/1", State: entity.BatchStateSpeculating}}
78+
pathSets := []entity.SpeculationPathSet{{BatchID: "q/1"}}
79+
want := []entity.Speculation{{
80+
Path: entity.SpeculationPath{Head: "q/1"},
81+
Action: entity.PathActionBuild,
82+
}}
83+
84+
iter := generatormock.NewMockPathIterator(ctrl)
85+
gen := generatormock.NewMockGenerator(ctrl)
86+
alloc := allocatormock.NewMockAllocator(ctrl)
87+
88+
gen.EXPECT().Open(gomock.Any(), batches, pathSets).Return(iter, nil)
89+
alloc.EXPECT().Allocate(gomock.Any(), pathSets, iter).Return(want, nil)
90+
91+
got, err := New(gen, alloc).Speculate(context.Background(), batches, pathSets)
92+
require.NoError(t, err)
93+
assert.Equal(t, want, got)
94+
}
95+
96+
func TestComposed_PropagatesGeneratorError(t *testing.T) {
97+
ctrl := gomock.NewController(t)
98+
99+
errOpen := errors.New("open boom")
100+
gen := generatormock.NewMockGenerator(ctrl)
101+
alloc := allocatormock.NewMockAllocator(ctrl)
102+
103+
gen.EXPECT().Open(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errOpen)
104+
// Allocate must not be called when Open fails (no alloc.EXPECT()).
105+
106+
_, err := New(gen, alloc).Speculate(context.Background(), nil, nil)
107+
require.ErrorIs(t, err, errOpen)
108+
}

0 commit comments

Comments
 (0)