Skip to content

Commit 9aab8df

Browse files
committed
feat(speculation): allocator contract and sticky impl
## Summary ### Why? Ranked candidates need a budget policy: something must decide which paths get the queue's limited concurrent-build slots, without double-funding paths that are already running or finished. ### What? Adds `submitqueue/extension/speculation/allocator` — the budget-spending contract the standard Speculator hands its candidate stream to — plus the `sticky` implementation and mocks. The Allocator owns terminal-path suppression: it reconciles the stream against the stored path sets by path ID, so an in-flight candidate keeps the slot it already holds and a terminal one is skipped. Budget accounting reads path status and nothing else — pending, building, and cancelling paths hold a slot until their builds actually stop — and the unit is deliberately coarse: one slot per concurrent build. `sticky` fills only free slots and never preempts a running build; its policy counterpart is a preempting allocator. Output is all-or-nothing (a partial list would fund an arbitrary prefix of the ranking), and a stored path with no status fails fast rather than guessing whether it holds a slot. ## Test Plan ✅ `bazel test //submitqueue/extension/speculation/allocator/...` — terminal candidates skipped per status, in-flight candidates kept, budget exhaustion, context cancellation, corrupt-status failure. ✅ `make fmt`, `make gazelle`, `make mocks`
1 parent 28e0ab5 commit 9aab8df

9 files changed

Lines changed: 555 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
load("@rules_go//go:def.bzl", "go_library")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["allocator.go"],
6+
importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/allocator",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//submitqueue/entity:go_default_library",
10+
"//submitqueue/extension/speculation/generator:go_default_library",
11+
],
12+
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# allocator
2+
3+
The `allocator` package defines a composition point used by the `standard` `Speculator`: an `Allocator` spends the queue's build budget over a candidate iterator. Like `generator`, it is **not** a controller-facing extension — an alternate `Speculator` need not use or expose this split — so there is no `Config` or `Factory` here; an `Allocator` is chosen when the `standard` `Speculator` is constructed.
4+
5+
`Allocate` pulls candidates in the order the iterator yields them and matches them against the queue's current path sets by path ID. A pending or building path keeps the slot it already holds rather than starting a second attempt. A path whose build already finished comes back round as a candidate — the generator enumerates the whole space — and is skipped. Neither draws on free budget.
6+
7+
Pending, building, and cancelling paths all charge the budget; a cancelling build holds its slot until it reaches a terminal state. Terminal paths charge nothing. `Allocate` returns the build and cancel actions that spend what is left.
8+
9+
A cancelled or expired context aborts the run with its error and no actions. The output is all-or-nothing on purpose: a partial list would fund an arbitrary prefix of the ranking, leaving the budget half-spent on whatever was pulled first.
10+
11+
Because cancellation is best-effort, an `Allocator` should not spend capacity it only expects a cancel to release. A build cancelled to make room keeps charging the budget until that cancel reaches a terminal state, so the queue converges over successive runs instead of oversubscribing the hard cap in one pass.
12+
13+
How the budget is measured is the implementation's choice. The simplest unit is a build: every path costs one slot, whatever its build does. An `Allocator` that understands build size — target count, historical cost — could weight paths instead and pack the budget more tightly.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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 allocator defines the budget-spending composition point used by the
16+
// standard Speculator. An Allocator spends the queue's build budget over a
17+
// candidate iterator. It is not a controller-facing extension — an alternate
18+
// Speculator need not use or expose this split.
19+
package allocator
20+
21+
//go:generate mockgen -source=allocator.go -destination=mock/allocator_mock.go -package=mock
22+
23+
import (
24+
"context"
25+
26+
"github.com/uber/submitqueue/submitqueue/entity"
27+
"github.com/uber/submitqueue/submitqueue/extension/speculation/generator"
28+
)
29+
30+
// Allocator decides how to spend the queue's build budget over the generator's
31+
// candidate stream, returning the build and cancel actions to take this run. How
32+
// it rations the budget across new candidates and paths already in flight — and
33+
// whether it preempts running builds — is the implementation's policy.
34+
type Allocator interface {
35+
// Allocate returns the build and cancel actions to take. It reconciles the
36+
// candidate stream against the queue's current path sets by path ID: a
37+
// candidate matching an in-flight path stays funded rather than starting a
38+
// new attempt, and one matching a path whose build already finished is
39+
// skipped — the generator enumerates the whole space, so suppressing
40+
// finished paths is the Allocator's job.
41+
//
42+
// A cancelled or expired ctx aborts with its error and no actions.
43+
Allocate(ctx context.Context, pathSets []entity.SpeculationPathSet, iter generator.Iterator) ([]entity.Speculation, error)
44+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
load("@rules_go//go:def.bzl", "go_library")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["allocator_mock.go"],
6+
importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/allocator/mock",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//submitqueue/entity:go_default_library",
10+
"//submitqueue/extension/speculation/generator:go_default_library",
11+
"@org_uber_go_mock//gomock:go_default_library",
12+
],
13+
)

submitqueue/extension/speculation/allocator/mock/allocator_mock.go

Lines changed: 58 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["sticky.go"],
6+
importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/allocator/sticky",
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+
],
13+
)
14+
15+
go_test(
16+
name = "go_default_test",
17+
srcs = ["sticky_test.go"],
18+
embed = [":go_default_library"],
19+
deps = [
20+
"//submitqueue/entity:go_default_library",
21+
"@com_github_stretchr_testify//assert:go_default_library",
22+
"@com_github_stretchr_testify//require:go_default_library",
23+
],
24+
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# sticky
2+
3+
The `sticky` `Allocator` fills only free budget slots and leaves every in-flight build running. Against the budget it counts the paths that still hold a slot: `pending`, `building`, and `cancelling` — a cancel is only a request, and the build keeps its slot until it actually stops. Only path status enters this — no batch state does, since the budget tracks builds occupying CI.
4+
5+
It then proposes a build for each new candidate, in order, until the budget fills. Two kinds of candidate are skipped, for different reasons. A candidate matching an already-funded path is holding a slot already — it is counted above — so it needs no additional slot and no second build action. A candidate whose path is terminal in the path sets holds no slot and needs none. Neither consumes free budget. It never proposes a cancel.
6+
7+
The budget is measured in builds: one slot per path, no matter what that path's build does. That is deliberately coarse — a twelve-hour build and a two-minute build cost the same slot. An allocator that understands build size (target count, historical duration) could pack the same budget more effectively; `sticky` trades that for simplicity.
8+
9+
A stored path with no status at all is a data defect, and `sticky` fails fast: `Allocate` returns an error rather than guessing what the record means.
10+
11+
The trade-off: a better candidate arriving while the budget is full waits for a running build to finish rather than displacing it. `sticky` never discards work already started, and it cannot react to a newly attractive path mid-flight. Its counterpart is a preempting `Allocator` that cancels a low-value in-flight path to fund a better one, spending a cancel now to converge faster on the next run.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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 sticky provides an allocator.Allocator that fills only free budget
16+
// slots and leaves every in-flight build running — it never preempts. Its policy
17+
// counterpart is a preempting allocator, which would cancel a low-value in-flight
18+
// path to fund a better candidate; sticky trades that responsiveness for never
19+
// discarding a build it has already started.
20+
package sticky
21+
22+
import (
23+
"context"
24+
"fmt"
25+
26+
"github.com/uber/submitqueue/submitqueue/entity"
27+
"github.com/uber/submitqueue/submitqueue/extension/speculation/allocator"
28+
"github.com/uber/submitqueue/submitqueue/extension/speculation/generator"
29+
)
30+
31+
// alloc is a sticky allocator.Allocator.
32+
type alloc struct {
33+
// budget is the queue's cap on concurrently held build slots, measured in
34+
// builds: one slot per path whose attempt is pending, building, or
35+
// cancelling, whatever that build's size. The unit is deliberately coarse —
36+
// an allocator that understands build size could weight paths instead.
37+
budget int
38+
}
39+
40+
// New returns a sticky allocator.Allocator with the given budget, measured in
41+
// concurrent builds.
42+
func New(budget int) allocator.Allocator {
43+
return alloc{budget: budget}
44+
}
45+
46+
// Allocate keeps every in-flight path funded and pulls candidates in order into
47+
// the remaining free budget, proposing a build for each new path until the
48+
// budget fills. It never proposes a cancel.
49+
//
50+
// A cancelled or expired ctx aborts with its error and no actions. The run's
51+
// output is all-or-nothing: a partial action list would fund an arbitrary
52+
// prefix of the ranking, so a caller that gave up mid-run gets nothing rather
53+
// than a half-spent budget.
54+
func (a alloc) Allocate(ctx context.Context, pathSets []entity.SpeculationPathSet, iter generator.Iterator) ([]entity.Speculation, error) {
55+
if err := ctx.Err(); err != nil {
56+
return nil, err
57+
}
58+
// Paths already holding a build slot. This set does double duty: it is both
59+
// the count of budget already spent and the guard against proposing a second
60+
// build for something already running, so a status belongs here if either
61+
// reason applies.
62+
funded := make(map[string]bool)
63+
// Paths whose build already finished. They hold no slot, but they must not
64+
// be built again either.
65+
terminal := make(map[string]bool)
66+
for _, set := range pathSets {
67+
for _, entry := range set.Paths {
68+
if entry.Status == entity.SpeculationPathStatusUnknown {
69+
// A stored path always carries a real status, so this record is
70+
// corrupt. Fail fast rather than guess: any reading of it — slot
71+
// held or free, buildable or not — could be wrong in a way that
72+
// either pins a slot forever or double-builds a path.
73+
return nil, fmt.Errorf("speculation path %q of head %q has no status", entry.ID, set.Head)
74+
}
75+
if entry.Status.IsTerminal() {
76+
terminal[entry.ID] = true
77+
continue
78+
}
79+
// Anything else that has not finished is still holding a slot:
80+
// pending and building obviously, and cancelling too, since the build
81+
// keeps its slot until it actually stops.
82+
//
83+
// Only the path's status matters. No batch state enters this
84+
// decision — "merging" and the rest are states of a batch, never of
85+
// a path — so the rule is simply that CI is still busy with it.
86+
funded[entry.ID] = true
87+
}
88+
}
89+
90+
free := a.budget - len(funded)
91+
92+
var out []entity.Speculation
93+
proposed := make(map[string]bool)
94+
for free > 0 {
95+
// Next reports cancellation too, but check here as well: a generator
96+
// that ignores ctx must not be able to spin this loop forever.
97+
if err := ctx.Err(); err != nil {
98+
return nil, err
99+
}
100+
c, ok, err := iter.Next(ctx)
101+
if err != nil {
102+
return nil, err
103+
}
104+
if !ok {
105+
// ok=false means the generator has nothing left to offer: every path
106+
// the queue could speculate on has been seen. Stopping here with
107+
// budget still free is normal, not a failure — there is simply
108+
// nothing else worth building right now.
109+
break
110+
}
111+
id := c.Path.ID()
112+
if terminal[id] {
113+
// This path's build already ran. The generator enumerates the whole
114+
// space with no knowledge of the path sets, so finished paths come
115+
// back round as candidates; they need no slot and must not be rebuilt.
116+
continue
117+
}
118+
if funded[id] || proposed[id] {
119+
// funded: this path is already building. It needs no new action and
120+
// is already charging the budget, so skip it without spending
121+
// anything.
122+
//
123+
// proposed: a generator is not supposed to repeat a candidate, so
124+
// this should never fire. It is a cheap guard against one that does,
125+
// since a duplicate would otherwise be paid for twice.
126+
continue
127+
}
128+
out = append(out, entity.Speculation{Path: c.Path, Action: entity.PathActionBuild})
129+
proposed[id] = true
130+
free--
131+
}
132+
return out, nil
133+
}

0 commit comments

Comments
 (0)