Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion doc/rfc/submitqueue/speculation-generator-best-first.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ The ordering stays the same. `CandidatePath.RankingScore` contains this logarith
- `Succeeded` fixes an assumption to succeeds.
- `Failed` or `Cancelled` fixes an assumption to fails.
- `Cancelling` remains undecided because cancellation may lose a race with completion.
- `Merging` also remains undecided, because a merge can fail. It is tempting to treat it as committed to landing and skip the scorer call, but that puts a state-specific policy inside the search: whether a path betting against a merging batch is worth funding is a question of price, and price belongs to the scorer. The allocator draws the same line — "no batch state enters this decision" — and the generator holds it too. Nothing is lost by staying open, because a head can never merge ahead of a dependency it took a position on (see [speculation.md](speculation.md)); the cost of an unlikely path is budget, which is the allocator's to ration.
- `Merging` also remains undecided, because a merge can fail. It is tempting to treat it as committed to landing and skip the scorer call, but that puts a state-specific policy inside the search: whether a path betting against a merging batch is worth funding is a question of price, and price belongs to the scorer. The allocator draws the same line — "no batch state enters this decision" — and the generator holds it too. Nothing is lost by staying open: a single passed path still waits for the merge result, while passed paths covering every outcome let the controller bypass the dependency (see [speculation.md](speculation.md)). Funding the unlikely side spends budget, which is the allocator's to ration.
- A fixed assumption stays in the returned path but contributes probability 1 and has no flip.
- A shared dependency is scored once per run.

Expand Down
8 changes: 4 additions & 4 deletions doc/rfc/submitqueue/speculation.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A merge queue that verifies one change at a time is limited by its slowest build

Work enters SubmitQueue as **batches** — changes verified and merged together. Two batches **conflict** when they touch the same code, which makes the earlier one a **dependency** of the later. A **path** is one set of assumptions about how a batch's dependencies resolve, and the batch it builds is the path's **head**.

On every queue update the **speculate controller** reruns from scratch: it reads the current state, applies the incoming signals, asks a pluggable **Speculator** which paths are worth building within the CI budget, and persists only those. Everything else is recomputed next time, never stored. Merging stays strict: a batch merges only after its dependencies resolve and a matching build has passed.
On every queue update the **speculate controller** reruns from scratch: it reads the current state, applies the incoming signals, asks a pluggable **Speculator** which paths are worth building within the CI budget, and persists only those. Everything else is recomputed next time, never stored. A batch normally merges after its dependencies resolve and a matching build has passed; complete passed coverage of every unresolved outcome lets it bypass those dependencies.

## The speculation run

Expand Down Expand Up @@ -54,7 +54,7 @@ Every write is a compare-and-swap: a writer that loses re-reads on a later run.

Verdicts are controller-owned facts: the Speculator can neither compute nor veto them.

- **Merge (strict).** Each path carries an assumption about every dependency — *succeeds* (built on top of) or *fails* (built without). Once a path's build has passed and every dependency has finished the way the path assumed — one assumed *succeeds* has merged, one assumed *fails* has failed or been cancelled — the speculate controller moves the head to Merging and hands it to Runway. A dependency that is merely *merging* has not finished, because a merge can fail, so it is still waited on. If that hand-off is lost, the next run re-sends it. The same run sets the head's remaining in-flight paths *cancelling*: once one path has passed the others cannot help, and they hold CI slots until they stop. The mergesignal controller records Runway's terminal result: success marks the head Succeeded, while failure marks it Failed. The result publishes a single dirty signal — no per-dependent fan-out — and the next run refutes paths whose assumption disagrees with the result: *fails* assumptions after success, *succeeds* assumptions after failure. The hand-off is idempotent, so Runway reports success without another merge when the change is already present. Down a chain, each head waits for its predecessors to settle, so a chain merges one at a time.
- **Merge.** Each path carries an assumption about every dependency — *succeeds* (built on top of) or *fails* (built without). Normally, once a path's build has passed and every dependency has finished the way the path assumed — one assumed *succeeds* has merged, one assumed *fails* has failed or been cancelled — the speculate controller moves the head to Merging and hands it to Runway. A dependency that is merely *merging* has not finished, because a merge can fail, so a single matching path still waits for the answer. Complete passed coverage is the exception described in Bypass large diff: it lets a head merge before those answers arrive. If the hand-off is lost, the next run re-sends it. The same run sets the head's remaining in-flight paths *cancelling*: once the head can merge they cannot help, and they hold CI slots until they stop. The mergesignal controller records Runway's terminal result: success marks the head Succeeded, while failure marks it Failed. The result publishes a single dirty signal — no per-dependent fan-out — and the next run refutes paths whose assumption disagrees with the result: *fails* assumptions after success, *succeeds* assumptions after failure. The hand-off is idempotent, so Runway reports success without another merge when the change is already present. A chain ordinarily merges one at a time, but a fully covered head can bypass its unsettled predecessors.
- **Failure (no viable path).** A batch fails when every possible future has a failed build — no path can pass, so it can never merge.
- **Cancel.** A cancelled batch is driven terminal: its in-flight paths are set *cancelling*, then the batch is marked Cancelled once they stop (see Cancellation).

Expand All @@ -74,9 +74,9 @@ Example of the payoff either way: `H` conflicts with `B1` and weak `B2`. Relax `

If a batch's passed builds cover *every* way its dependencies could resolve, the outcome is the same either way — so it can merge now, ahead of them. Classic case: a small change stuck behind a slow one is built both with and without it; both pass, and it merges immediately.

The default Speculator covers the whole space only when doing so is cheap enough, and funds the extra candidates within the build budget. The controller merges early only when a passed path exists for every combination of the dependencies — it reads that straight off the path records. If any combination is missing or unbuilt, the head waits normally.
The controller checks coverage over only the dependencies that have not settled yet. Settled dependencies pin each surviving path to the outcome that actually happened; for every combination of the remaining dependencies, the path set must contain a passed, unbroken path with that combination of assumptions. If any combination is missing, unbuilt, failed, or contradicted by a settled dependency, the head waits normally. The check only observes paths the Speculator already funded — it does not fund the exponential path space itself or alter the queue's build budget.

**Not yet implemented on the controller side.** `decide`/`mergeablePath` gate on a single passed path whose assumptions have all been settled by the dependency's actual state; nothing enumerates the combinations. The distinction matters: a *single* passed path that assumed a dependency would fail is not complete coverage, and merging on it while that dependency is still live would put a combination on the trunk that no build validated. Coverage is what makes early merge sound — one path betting the right way is not.
Coverage makes the bypass sound because whichever way the dependencies later resolve, a passed build already validated the resulting set of changes. The build order and merge order differ: a path assuming dependency `D` succeeds validates `D` then head `H`, while bypass lands `H` before `D`. SubmitQueue treats those orders as content-equivalent. Runway still performs the real merge, so if the reordered changes conflict textually, the older dependency can fail after the newer head has bypassed it; this is an accepted cost of landing the fully covered head early rather than a licence to put unmergeable content on the target.

### Cancellation

Expand Down
4 changes: 2 additions & 2 deletions submitqueue/entity/request_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ const (
RequestStatusBatched RequestStatus = "batched"

// RequestStatusSpeculating indicates that the batch containing the request is in speculation:
// planning, building, or waiting for its dependencies to settle. None of those leaves it able to land.
// planning, building, or waiting until either its dependencies settle or passed paths cover every possible outcome.
RequestStatusSpeculating RequestStatus = "speculating"

// RequestStatusSpeculated indicates that the batch containing the request has finished speculating:
// a build passed on a path whose assumptions all held, and the batch has been cleared to merge.
// either a passed path's assumptions all held, or passed paths cover every outcome of its unsettled dependencies.
RequestStatusSpeculated RequestStatus = "speculated"

// RequestStatusLanding indicates that the request is actively being landed (e.g., source control operation is in progress to push the change to the target branch).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ The `standard` `Speculator` funds the queue's most promising speculation paths f

Each run it considers candidate paths in descending order of their probability of being the future that actually happens, and proposes builds down that ranking. Paths already pending or building keep the slot they hold rather than restarting; paths whose builds already finished are skipped for as long as their records remain in the supplied path sets, so a finished path can be proposed again — for a retry, say — once retention drops it; new builds fill whatever budget remains.

When the budget runs out, everything below the cut waits for a later run. That is safe because a batch's verdict never depends on what was funded — only on how its dependencies resolve and which builds pass.
When the budget runs out, everything below the cut waits for a later run. That is safe because the propose-side cannot invent a batch verdict: the speculate controller still decides merge from the persisted paths, including complete coverage of unsettled dependencies.

Both halves are swappable. The ranking is the `Generator`'s: the default `bestfirst` scores each path by the probability that all its assumptions hold. The budget policy is the `Allocator`'s: the default `sticky` fills only free slots and never preempts, where a preempting allocator would cancel a low-value in-flight path to fund a better one.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ load("@rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "go_default_library",
srcs = [
"bypass.go",
"check.go",
"dispatch.go",
"doc.go",
Expand Down
79 changes: 79 additions & 0 deletions submitqueue/orchestrator/controller/speculate/bypass.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) 2025 Uber Technologies, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package speculate

import "github.com/uber/submitqueue/submitqueue/entity"

// bypassablePath returns a passed path once passed builds cover every possible
// outcome of the head's unsettled dependencies. Settled dependencies stay
// pinned to reality through assumptionBroken.
//
// A path's combination is read positionally — the i-th assumption belongs to
// the head's i-th dependency — which isWellFormed's order check is what
// licenses: two paths with the same assumptions in different orders are
// different stacks, not the same outcome.
func bypassablePath(head entity.Batch, set entity.SpeculationPathSet, snap snapshot) (entity.SpeculationPathEntry, bool) {
unsettled := unsettledDependencyIndices(head, snap)
if len(unsettled) == 0 {
return entity.SpeculationPathEntry{}, false
}

required := 1
for range unsettled {
if required > len(set.Paths)/2 {
return entity.SpeculationPathEntry{}, false
}
required *= 2
}

seen := make(map[string]struct{}, required)
var winner entity.SpeculationPathEntry
for _, entry := range set.Paths {
if entry.Status != entity.SpeculationPathStatusPassed ||
assumptionBroken(entry.Path, snap) ||
!isWellFormed(entry.Path, head) {
continue
}

signature := make([]byte, len(unsettled))
for i, depIndex := range unsettled {
if entry.Path.Dependencies[depIndex].Assumption == entity.DependencyAssumptionFails {
signature[i] = 'f'
} else {
signature[i] = 's'
}
}
key := string(signature)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
if len(seen) == 1 {
winner = entry
}
}

return winner, len(seen) == required
}

func unsettledDependencyIndices(head entity.Batch, snap snapshot) []int {
var indices []int
for i, depID := range head.Dependencies {
if !snap.batchState(depID).IsTerminal() {
indices = append(indices, i)
}
}
return indices
}
22 changes: 11 additions & 11 deletions submitqueue/orchestrator/controller/speculate/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const (
rejectHeadNotSpeculating rejection = "head_not_speculating"
// rejectMalformedPath is a path whose assumptions do not line up with its
// head's dependency list: one missing or extra, a duplicate, a wrong head,
// or a made-up assumption value.
// an assumption out of queue order, or a made-up assumption value.
rejectMalformedPath rejection = "malformed_path"
// rejectBrokenAssumption is a path with an assumption a finished
// dependency has already proven wrong.
Expand Down Expand Up @@ -122,7 +122,13 @@ func rejectionReason(proposal entity.Speculation, snap snapshot) (rejection, boo

// isWellFormed reports whether a path is a proper guess about its head:
// exactly one assumption for each of the head's dependencies, no more and no
// fewer, and every assumption a real value.
// fewer, in the head's dependency order, and every assumption a real value.
//
// Order is load-bearing: Base() projects the path positionally, so a path
// whose dependencies are permuted describes a stack the build runner applied
// in a different order — a different tree, which no verdict may count as the
// combination its assumptions name. With the length check and position-wise
// equality, a missing, extra, or duplicate dependency is also impossible.
//
// A malformed path is not merely suboptimal, it is unmergeable — the merge
// preconditions are read off the path's assumptions (see mergeablePath), so a
Expand All @@ -135,16 +141,10 @@ func isWellFormed(path entity.SpeculationPath, head entity.Batch) bool {
return false
}

required := make(map[string]struct{}, len(head.Dependencies))
for _, dep := range head.Dependencies {
required[dep] = struct{}{}
}

for _, dep := range path.Dependencies {
if _, ok := required[dep.Batch]; !ok {
for i, dep := range path.Dependencies {
if dep.Batch != head.Dependencies[i] {
return false
}
delete(required, dep.Batch)

switch dep.Assumption {
case entity.DependencyAssumptionSucceeds,
Expand All @@ -154,7 +154,7 @@ func isWellFormed(path entity.SpeculationPath, head entity.Batch) bool {
}
}

return len(required) == 0
return true
}

// findPath returns the entry for a path ID in the set.
Expand Down
16 changes: 14 additions & 2 deletions submitqueue/orchestrator/controller/speculate/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ func TestFilterProposals_Rejects(t *testing.T) {
snap: checkSnapshot(entity.BatchStateSpeculating),
want: rejectMalformedPath,
},
{
name: "path with dependencies out of queue order",
proposal: entity.Speculation{
Path: entity.SpeculationPath{Head: head, Dependencies: []entity.PathDependency{
{Batch: dep2, Assumption: entity.DependencyAssumptionFails},
{Batch: dep1, Assumption: entity.DependencyAssumptionSucceeds},
}},
Action: entity.PathActionBuild,
},
snap: checkSnapshot(entity.BatchStateSpeculating),
want: rejectMalformedPath,
},
{
name: "cancel on a path that is not stored",
proposal: entity.Speculation{Path: valid, Action: entity.PathActionCancel},
Expand Down Expand Up @@ -208,12 +220,12 @@ func TestIsWellFormed(t *testing.T) {
want: true,
},
{
name: "order does not matter",
name: "dependencies out of queue order",
path: entity.SpeculationPath{Head: head, Dependencies: []entity.PathDependency{
{Batch: dep2, Assumption: entity.DependencyAssumptionSucceeds},
{Batch: dep1, Assumption: entity.DependencyAssumptionFails},
}},
want: true,
want: false,
},
{
name: "missing a dependency",
Expand Down
14 changes: 8 additions & 6 deletions submitqueue/orchestrator/controller/speculate/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
// speculation everything is serial: C waits for B, B waits for A. Speculation
// builds a batch against a guess about how its dependencies turn out. When
// the guess holds, the batch merges the moment the guessed-on dependencies
// land — it never waits for a build of its own to start afterwards.
// land. If passed paths cover every possible outcome, the batch can merge
// before those dependencies settle.
//
// # Paths
//
Expand All @@ -46,6 +47,8 @@
//
// Fund both and every future is covered:
//
// - While A is still unresolved, both P1 and P2 passing lets B bypass A and
// merge immediately: either possible future has already been validated.
// - A succeeds and P1 passed: B merges the moment A lands. P2's guess
// ("A fails") is broken — it can no longer come true — so its build is
// cancelled to free the slot.
Expand Down Expand Up @@ -75,9 +78,8 @@
// building, and every pending, building, and cancelling path holds its slot
// until its build stops. A path is broken once a dependency's actual result
// proves one of its assumptions wrong: its guess can no longer come true, so
// its build is cancelled to free the slot. A path is superseded when a
// sibling path of the same head passes — that sibling will carry the head out
// of the queue, so the others are cancelled too.
// its build is cancelled to free the slot. A path is superseded when its head
// becomes mergeable, so any still-running siblings are cancelled too.
//
// Cancelling is intent, not fact: the build keeps its slot until CI actually
// stops it, and only an observation of that stop (or proof nothing was ever
Expand All @@ -89,8 +91,8 @@
//
// # The life of a batch, as seen from here
//
// Created ──admit──► Speculating ──┬── merge ──► Merging (merge stage takes over)
// └── fail ───► Failed
// Created ──admit──► Speculating ──┬── merge or bypass ──► Merging
// └── fail ─────────────► Failed
// user cancel (cancel stage):
// ... ──► Cancelling ── every path stopped ──► Cancelled
//
Expand Down
Loading
Loading