Skip to content

Commit eb3fc58

Browse files
behinddwallsgithub-actions[bot]
authored andcommitted
feat(orchestrator)!: finalize batches from their speculation paths
## Summary ### Why? Speculation was building the right things and merging on the wrong rule. A head still waited for *every* dependency to succeed before it could merge — the same rule as before any of this — so a batch built without a slow neighbour sat behind that neighbour anyway. The paths were being earned and then ignored. This is the commit that collects them. ### What? A head merges once one of its **passed** paths has had every dependency it *assumed would succeed* actually merge; assumed-failing and ignored dependencies impose no wait. That is the whole speedup: the head waits on what its passing build was actually stacked on, not on its full dependency list. A head fails only when no funded path has a future left, and the winner's siblings are superseded — cancelled to free their slots. Outcomes are reached in `finalize`, before the Speculator is asked, and committed one generation at a time, so a cascade (A fails → B's last path breaks → B fails → maybe C too) resolves in a single run without ever enacting a dependent of an outcome whose own write lost its compare-and-swap. A batch decided by a cascade gets a recovery signal before it turns terminal, since no retry of the triggering message would ever revisit it. Cancellation joins the run: a cancelling batch is just another batch the run walks — its live paths are marked cancelling, the poll loop stops their builds, and whichever later run sees them all stopped drives the batch to Cancelled. A cancelling path with no build link is cancelled immediately; there is deliberately no reservation state and no staleness bound, so a crashed dispatch can never keep a batch out of its terminal state. The legacy per-batch finalizer is deleted; `speculate.go` keeps only admission and message routing, and the package doc gains the batch lifecycle, a worked example, and the finalize step. ## Test Plan ✅ `bazel test //submitqueue/orchestrator/...` — the merge rule is table-driven per assumption; failure, cascade commit ordering, and CAS-loss isolation are covered, and cancellation end to end: never-dispatched, live-build, no-paths, and lost-race cases. ✅ `make fmt`, `make gazelle` # Conflicts: # submitqueue/orchestrator/controller/speculate/BUILD.bazel # submitqueue/orchestrator/controller/speculate/speculate_test.go # Please enter the commit message for your changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # An empty message aborts the commit. # # interactive rebase in progress; onto ec05b712 # Last command done (1 command done): # pick 4462c89 # feat(orchestrator)!: finalize batches from their speculation paths # No commands remaining. # You are currently rebasing branch 'preetam/speculation-finalization' on 'ec05b712'. # # Changes to be committed: # modified: submitqueue/orchestrator/controller/cancel/cancel.go # modified: submitqueue/orchestrator/controller/speculate/BUILD.bazel # modified: submitqueue/orchestrator/controller/speculate/check.go # modified: submitqueue/orchestrator/controller/speculate/dispatch.go # modified: submitqueue/orchestrator/controller/speculate/doc.go # new file: submitqueue/orchestrator/controller/speculate/finalize.go # new file: submitqueue/orchestrator/controller/speculate/outcome.go # new file: submitqueue/orchestrator/controller/speculate/outcome_test.go # modified: submitqueue/orchestrator/controller/speculate/run.go # modified: submitqueue/orchestrator/controller/speculate/run_test.go # modified: submitqueue/orchestrator/controller/speculate/snapshot.go # modified: submitqueue/orchestrator/controller/speculate/speculate.go # modified: submitqueue/orchestrator/controller/speculate/speculate_test.go # # Conflicts: # submitqueue/orchestrator/controller/speculate/speculate.go # Please enter the commit message for your changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # An empty message aborts the commit. # # interactive rebase in progress; onto 9a490fe # Last command done (1 command done): # pick ec12146f # feat(orchestrator)!: finalize batches from their speculation paths # No commands remaining. # You are currently rebasing branch 'preetam/speculation-finalization' on '9a490fe4'. # # Changes to be committed: # modified: submitqueue/orchestrator/controller/speculate/BUILD.bazel # modified: submitqueue/orchestrator/controller/speculate/check.go # modified: submitqueue/orchestrator/controller/speculate/dispatch.go # modified: submitqueue/orchestrator/controller/speculate/doc.go # new file: submitqueue/orchestrator/controller/speculate/finalize.go # new file: submitqueue/orchestrator/controller/speculate/outcome.go # new file: submitqueue/orchestrator/controller/speculate/outcome_test.go # modified: submitqueue/orchestrator/controller/speculate/run.go # modified: submitqueue/orchestrator/controller/speculate/run_test.go # modified: submitqueue/orchestrator/controller/speculate/snapshot.go # modified: submitqueue/orchestrator/controller/speculate/speculate.go # modified: submitqueue/orchestrator/controller/speculate/speculate_test.go # # Conflicts: # submitqueue/orchestrator/controller/speculate/dispatch.go # submitqueue/orchestrator/controller/speculate/run.go # submitqueue/orchestrator/controller/speculate/run_test.go # submitqueue/orchestrator/controller/speculate/speculate.go # submitqueue/orchestrator/controller/speculate/speculate_test.go # Please enter the commit message for your changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # An empty message aborts the commit. # # interactive rebase in progress; onto e6ea890a # Last command done (1 command done): # pick 25e0321 # feat(orchestrator)!: finalize batches from their speculation paths # No commands remaining. # You are currently rebasing branch 'preetam/speculation-finalization' on 'e6ea890a'. # # Changes to be committed: # modified: submitqueue/orchestrator/controller/speculate/BUILD.bazel # modified: submitqueue/orchestrator/controller/speculate/check.go # modified: submitqueue/orchestrator/controller/speculate/dispatch.go # modified: submitqueue/orchestrator/controller/speculate/doc.go # new file: submitqueue/orchestrator/controller/speculate/finalize.go # new file: submitqueue/orchestrator/controller/speculate/outcome.go # new file: submitqueue/orchestrator/controller/speculate/outcome_test.go # modified: submitqueue/orchestrator/controller/speculate/run.go # modified: submitqueue/orchestrator/controller/speculate/run_test.go # modified: submitqueue/orchestrator/controller/speculate/snapshot.go # modified: submitqueue/orchestrator/controller/speculate/speculate.go # modified: submitqueue/orchestrator/controller/speculate/speculate_test.go #
1 parent 8d5a6c2 commit eb3fc58

12 files changed

Lines changed: 1860 additions & 1038 deletions

File tree

submitqueue/orchestrator/controller/speculate/BUILD.bazel

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ go_library(
66
"check.go",
77
"dispatch.go",
88
"doc.go",
9+
"finalize.go",
10+
"outcome.go",
911
"run.go",
1012
"snapshot.go",
1113
"speculate.go",
@@ -30,6 +32,7 @@ go_test(
3032
name = "go_default_test",
3133
srcs = [
3234
"check_test.go",
35+
"outcome_test.go",
3336
"run_test.go",
3437
"snapshot_test.go",
3538
"speculate_test.go",
@@ -39,7 +42,6 @@ go_test(
3942
"//platform/base/messagequeue:go_default_library",
4043
"//platform/consumer:go_default_library",
4144
"//platform/consumer/mock:go_default_library",
42-
"//platform/errs:go_default_library",
4345
"//platform/extension/messagequeue/mock:go_default_library",
4446
"//submitqueue/core/topickey:go_default_library",
4547
"//submitqueue/entity:go_default_library",

submitqueue/orchestrator/controller/speculate/check.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,8 @@ func rejectionReason(proposal entity.Speculation, snap snapshot) (rejection, boo
125125
// fewer, and every assumption a real value.
126126
//
127127
// A malformed path is not merely suboptimal, it is unmergeable — the merge
128-
// preconditions are read off the path's assumptions, so a path missing a
129-
// dependency would let its head merge without waiting for it.
128+
// preconditions are read off the path's assumptions (see mergeablePath), so a
129+
// path missing a dependency would let its head merge without waiting for it.
130130
func isWellFormed(path entity.SpeculationPath, head entity.Batch) bool {
131131
if path.Head != head.ID {
132132
return false

submitqueue/orchestrator/controller/speculate/dispatch.go

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,19 @@ import (
2626
"github.com/uber/submitqueue/submitqueue/extension/storage"
2727
)
2828

29-
// dispatch saves each head's decisions and hands the build stage its work.
30-
// Everything decided this run — build results, broken-path cancellations and
29+
// dispatch saves what finalize left over and hands the build stage its work.
30+
// Everything still outstanding — build results, broken-path cancellations and
3131
// accepted proposals — is folded together per head, so a head costs one
3232
// compare-and-swap however many of its paths changed.
33-
func (c *Controller) dispatch(ctx context.Context, store storage.Storage, queue string, snap snapshot, kept []entity.Speculation) error {
33+
//
34+
// It walks every in-flight batch, not only the speculating ones. Proposals
35+
// apply to speculating heads alone and are simply absent for the rest, but
36+
// observations are not: a merging or cancelling head's paths keep holding CI
37+
// slots until their builds stop, and this is the only writer that can record
38+
// that they have. Batches already finalized arrive here clean —
39+
// commitOutcome persisted their set with their outcome — so only their
40+
// dispatch is left to do.
41+
func (c *Controller) dispatch(ctx context.Context, queue string, snap snapshot, kept []entity.Speculation) error {
3442
nowMs := time.Now().UnixMilli()
3543

3644
// Group the accepted proposals by head so each head is written once.
@@ -39,7 +47,7 @@ func (c *Controller) dispatch(ctx context.Context, store storage.Storage, queue
3947
byHead[proposal.Path.Head] = append(byHead[proposal.Path.Head], proposal)
4048
}
4149

42-
for _, batch := range snap.speculating {
50+
for _, batch := range snap.inFlight {
4351
// A head with no stored set is one nothing has been funded for yet. It
4452
// gets an empty set to fold this run's proposals into, which persist
4553
// then creates; a head that ends the run with no paths writes nothing
@@ -57,7 +65,7 @@ func (c *Controller) dispatch(ctx context.Context, store storage.Storage, queue
5765
}
5866

5967
if changed {
60-
if err := c.persist(ctx, store, set, exists); err != nil {
68+
if _, err := c.persist(ctx, snap.store, set, exists); err != nil {
6169
if errors.Is(err, storage.ErrVersionMismatch) {
6270
// Skipped rather than failed, and nothing is lost by that.
6371
//
@@ -101,8 +109,9 @@ func (c *Controller) dispatch(ctx context.Context, store storage.Storage, queue
101109
}
102110

103111
// persist writes a head's path set, creating it if this run is the first to
104-
// fund the head.
105-
func (c *Controller) persist(ctx context.Context, store storage.Storage, set entity.SpeculationPathSet, exists bool) error {
112+
// fund the head. It returns the set as stored, with its version advanced, so
113+
// a caller that keeps the set around goes on holding a current copy.
114+
func (c *Controller) persist(ctx context.Context, store storage.Storage, set entity.SpeculationPathSet, exists bool) (entity.SpeculationPathSet, error) {
106115
pathSets := store.GetSpeculationPathSetStore()
107116

108117
if !exists {
@@ -111,23 +120,24 @@ func (c *Controller) persist(ctx context.Context, store storage.Storage, set ent
111120
if errors.Is(err, storage.ErrAlreadyExists) {
112121
// Another writer created it between this run's read and now.
113122
// Treat it as a lost race: the next run reads the winner.
114-
return storage.ErrVersionMismatch
123+
return set, storage.ErrVersionMismatch
115124
}
116125
metrics.NamedCounter(c.metricsScope, opName, "storage_errors", 1)
117-
return fmt.Errorf("failed to create path set for batch %s: %w", set.Head, err)
126+
return set, fmt.Errorf("failed to create path set for batch %s: %w", set.Head, err)
118127
}
119-
return nil
128+
return set, nil
120129
}
121130

122131
newVersion := set.Version + 1
123132
if err := pathSets.Update(ctx, set, set.Version, newVersion); err != nil {
124133
if errors.Is(err, storage.ErrVersionMismatch) {
125-
return err
134+
return set, err
126135
}
127136
metrics.NamedCounter(c.metricsScope, opName, "storage_errors", 1)
128-
return fmt.Errorf("failed to update path set for batch %s: %w", set.Head, err)
137+
return set, fmt.Errorf("failed to update path set for batch %s: %w", set.Head, err)
129138
}
130-
return nil
139+
set.Version = newVersion
140+
return set, nil
131141
}
132142

133143
// applyProposal folds one accepted proposal into the set and reports whether

submitqueue/orchestrator/controller/speculate/doc.go

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,19 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
// Package speculate plans a queue's speculative builds: which guesses about
16-
// the queue's future are worth building, within the queue's cap on concurrent
17-
// builds (the build budget).
18-
//
19-
// Batch outcomes — merge or fail — are still decided by the legacy per-batch
20-
// finalizer in speculate.go, which waits on every dependency. Deriving them
21-
// from the paths planned here replaces it in the next change; this package
22-
// doc grows with it.
15+
// Package speculate plans a queue's speculative builds and finalizes each
16+
// batch's outcome from their results. It is the orchestrator's decision
17+
// stage: builds are started by the build stage and watched — and stopped —
18+
// by the buildsignal stage, but what to build and what a finished build
19+
// means are decided here.
2320
//
2421
// # Why speculation
2522
//
2623
// Batches in a queue depend on the batches ahead of them, so without
2724
// speculation everything is serial: C waits for B, B waits for A. Speculation
2825
// builds a batch against a guess about how its dependencies turn out. When
29-
// the guess holds, the head's build has already run by the time its
30-
// dependencies resolve — it never waits for a build of its own to start
31-
// afterwards.
26+
// the guess holds, the batch merges the moment the guessed-on dependencies
27+
// land — it never waits for a build of its own to start afterwards.
3228
//
3329
// # Paths
3430
//
@@ -38,6 +34,26 @@
3834
// a path *is* its guess; building the same guess again is a new attempt of
3935
// the same path, and (path ID, attempt) names the resulting build.
4036
//
37+
// # A worked example
38+
//
39+
// A two-batch queue, where B depends on A and A is still building:
40+
//
41+
// queue: A ← B
42+
//
43+
// B's speculation space is two paths:
44+
// P1 = [A succeeds] B built on top of A's result
45+
// P2 = [A fails] B built without A
46+
//
47+
// Fund both and every future is covered:
48+
//
49+
// - A succeeds and P1 passed: B merges the moment A lands. P2's guess
50+
// ("A fails") is broken — it can no longer come true — so its build is
51+
// cancelled to free the slot.
52+
// - A fails and P2 passed: B merges without A, again with no new build.
53+
// P1's guess is broken.
54+
// - A resolved either way, and every unbroken path failed: no future
55+
// remains in which B passes, so B fails.
56+
//
4157
// # The life of a path
4258
//
4359
// A path's status tracks its current attempt:
@@ -46,10 +62,11 @@
4662
// (no entry) ─────────► pending ─────────► building ──────┬──► passed
4763
// │ │ └──► failed
4864
// "stop this": │ │
49-
// broken or ▼ ▼
50-
// preempted ─────► cancelling ◄────┘
51-
// │
52-
// │ build observed stopped
65+
// broken, ▼ ▼
66+
// superseded, ─────► cancelling ◄────┘
67+
// head cancelled, │
68+
// or preempted │ build observed stopped, or
69+
// │ nothing was ever dispatched
5370
// ▼
5471
// cancelled
5572
//
@@ -58,14 +75,27 @@
5875
// building, and every pending, building, and cancelling path holds its slot
5976
// until its build stops. A path is broken once a dependency's actual result
6077
// proves one of its assumptions wrong: its guess can no longer come true, so
61-
// its build is cancelled to free the slot.
78+
// its build is cancelled to free the slot. A path is superseded when a
79+
// sibling path of the same head passes — that sibling will carry the head out
80+
// of the queue, so the others are cancelled too.
6281
//
6382
// Cancelling is intent, not fact: the build keeps its slot until CI actually
64-
// stops it, and only an observation of that stop moves the path to cancelled.
65-
// The intent needs no dispatch of its own — the poll loop reads it off the set
66-
// and asks the runner to stop the build. A terminal path can be resurrected by
67-
// a new build proposal — status returns to pending and Attempt increments, the
68-
// one backwards step in the diagram.
83+
// stops it, and only an observation of that stop (or proof nothing was ever
84+
// dispatched) moves the path to cancelled. The intent needs no dispatch of its
85+
// own — the poll loop reads it off the set and asks the runner to stop the
86+
// build. A terminal path can be resurrected by a new build proposal — status
87+
// returns to pending and Attempt increments, the one backwards step in the
88+
// diagram.
89+
//
90+
// # The life of a batch, as seen from here
91+
//
92+
// Created ──admit──► Speculating ──┬── merge ──► Merging (merge stage takes over)
93+
// └── fail ───► Failed
94+
// user cancel (cancel stage):
95+
// ... ──► Cancelling ── every path stopped ──► Cancelled
96+
//
97+
// Failed and Cancelled fan out to the conclude stage, which reconciles the
98+
// batch's requests.
6999
//
70100
// # How a run works
71101
//
@@ -75,16 +105,18 @@
75105
// reordered signals are harmless, and a later run repairs whatever an
76106
// earlier one left half-done.
77107
//
78-
// signal ──► read ──► cancel ──► ask ──► check ──► dispatch
79-
// one broken the filter save changes,
80-
// read of paths Specu- its hand builds to
81-
// queue + lator proposals the build stage
82-
// paths
108+
// signal ──► read ──► finalize ──► ask ──► check ──► dispatch
109+
// one enact the the filter save changes,
110+
// read of outcomes Specu- its hand builds to
111+
// queue + the facts lator proposals the build stage
112+
// paths already
113+
// decide
83114
//
84115
// The Speculator is the extension that proposes which paths to fund or
85-
// preempt. It only ever proposes: check.go filters its answer, and broken
86-
// paths are cancelled before it is asked, so it reasons over facts as they
87-
// now stand rather than over a picture the run is about to invalidate.
116+
// preempt. It only ever proposes: check.go filters its answer, and outcomes
117+
// are computed here, never by the extension. Finalize runs before ask so the
118+
// Speculator reasons over facts as they now stand, not over a picture the run
119+
// is about to invalidate.
88120
//
89121
// # Ownership
90122
//

0 commit comments

Comments
 (0)