diff --git a/doc/rfc/submitqueue/speculation.md b/doc/rfc/submitqueue/speculation.md index 600819bf..6f3d460e 100644 --- a/doc/rfc/submitqueue/speculation.md +++ b/doc/rfc/submitqueue/speculation.md @@ -62,7 +62,7 @@ Verdicts are controller-owned facts: the Speculator can neither compute nor veto Conflict analysis is conservative — it flags any *possible* conflict — so heads carry dependencies that rarely matter and over-serialize. Relaxation lets the Speculator **drop** the weakest: the path tags that dependency *dropped* and ignores it — it neither gates the merge nor refutes the path if it lands. Which to drop is a per-run Speculator policy. -The drop lives on the path, so the path stays self-describing: finalization needs no external relaxed set, and dropped dependencies don't count toward the depth bound (relaxing is what shrinks a head's depth). +The drop lives on the path, so the path stays self-describing: finalization needs no external relaxed set (relaxing is what shrinks the space of guesses a head's paths range over). Example: `H` conflicts with `B1` and weak `B2`. Drop `B2`, and `H` merges once `B1` lands and its build passes — even if `B2` later lands. Without it, `H` waits on both. @@ -95,14 +95,14 @@ The one extension. It decides *which paths to build and which running ones to ca - `batches`: every in-flight batch plus finalized batches still referenced as dependencies by an in-flight batch; each carries its dependency list and state. - `pathSets`: every materialized path for those batches, whether pending, in flight, or terminal, including recently finished paths retained to prevent duplicate work. - **Out:** a list of build/cancel actions whose heads are in `BatchStateSpeculating`; batches in every other state provide facts but are never action targets. -- Budget, depth bound, and clock are injected at construction. An impl may read extra data (also injected); the controller checks the output, so extra data never affects correctness. +- Budget and clock are injected at construction. An impl may read extra data (also injected); the controller checks the output, so extra data never affects correctness. ### The default Speculator -The default Speculator is composed from two swappable interfaces — a **Generator** and an **Allocator** — so scoring and preemption policy can vary independently. They are composition points inside the default implementation, not controller-facing extensions: the controller depends only on the Speculator contract, and an alternate Speculator need not use or expose this split. The default opens the Generator's candidate stream over the batches and their path sets, then hands that stream and the path sets to the Allocator. +The default Speculator is composed from two swappable interfaces — a **Generator** and an **Allocator** — so scoring and preemption policy can vary independently. They are composition points inside the default implementation, not controller-facing extensions: the controller depends only on the Speculator contract, and an alternate Speculator need not use or expose this split. The default opens the Generator's candidate stream over the batches, then hands that stream and the path sets to the Allocator. -- **Generator** — yields the queue's candidate paths as one iterator, best-first across heads in `BatchStateSpeculating`. *Contract:* every candidate has a Speculating head, is coherent, and is within the depth bound (the count of unresolved dependencies a head's paths range over); none repeats or contradicts a resolved fact; a head past the bound is skipped until its dependencies resolve. Ranking is implementation-owned: the Generator may compute it directly, call an injected scorer extension, or use other injected data. It carries the resulting ranking score only within the run and skips paths already terminal in the path sets. -- **Allocator** — spends the build budget (the queue's cap on concurrent builds) over the iterator. *Contract:* it pulls in order until the budget fills and matches candidates to existing paths by ID, so a pending or building path remains funded rather than starting a new attempt; pending dispatches are replayed by the controller as described above. Pending, building, and cancelling paths charge the budget (a cancelling build holds CI until terminal), while terminal ones charge none. Cancellation is best-effort, so the Allocator does not spend capacity it merely expects a cancel to release and risk exceeding the hard CI cap. *Default:* the sticky policy fills only free slots and leaves in-flight builds running; a preempting policy cancels in-flight paths below the funded set. Budget is the only rationing lever — there is no ranking-score floor. A build cancelled to make room still charges budget until its cancel reaches terminal and publishes dirty, so the next run funds the released slot — the queue converges over successive ticks rather than oversubscribing in a single pass. +- **Generator** — yields the queue's candidate paths as one iterator, best-first across heads in `BatchStateSpeculating`. *Contract:* every candidate has a Speculating head and is coherent; none repeats or contradicts a resolved fact. Ranking is implementation-owned: the Generator may compute it directly, call an injected scorer extension, or use other injected data. It carries the resulting ranking score only within the run. +- **Allocator** — spends the build budget (the queue's cap on concurrent builds) over the iterator. *Contract:* it pulls in order until the budget fills and matches candidates to existing paths by ID, so a pending or building path remains funded rather than starting a new attempt, and a candidate whose path is already terminal in the path sets is skipped rather than rebuilt; pending dispatches are replayed by the controller as described above. Pending, building, and cancelling paths charge the budget (a cancelling build holds CI until terminal), while terminal ones charge none. Cancellation is best-effort, so the Allocator does not spend capacity it merely expects a cancel to release and risk exceeding the hard CI cap. *Default:* the sticky policy fills only free slots and leaves in-flight builds running; a preempting policy cancels in-flight paths below the funded set. Budget is the only rationing lever — there is no ranking-score floor. A build cancelled to make room still charges budget until its cancel reaches terminal and publishes dirty, so the next run funds the released slot — the queue converges over successive ticks rather than oversubscribing in a single pass. ### Extension APIs @@ -123,24 +123,24 @@ type SpeculationPathEntry struct { // SpeculationPath identifies a head batch and its ordered dependency bets; every // dependency appears exactly once, so the logical path is self-describing. type SpeculationPath struct { - HeadBatchID string // ID of the batch being built - Bets []DependencyBet // one bet per dependency of HeadBatchID, in queue order + Head string // ID of the batch being built + Bets []DependencyBet // one bet per dependency of Head, in queue order } // DependencyBet is the path's bet on one dependency. type DependencyBet struct { - BatchID string // dependency batch ID - Bet DependencyBetType // included | excluded | dropped + Batch string // dependency batch ID + Bet DependencyBetType // included | excluded | dropped } // DependencyBetType is how a path treats one dependency. -type DependencyBetType int +type DependencyBetType string const ( - BetUnknown DependencyBetType = 0 // zero-value sentinel; never valid - BetIncluded DependencyBetType = 1 // bet it lands; head built on top of it, refuted if it fails - BetExcluded DependencyBetType = 2 // bet it does not land; refuted if it lands - BetDropped DependencyBetType = 3 // dropped by relaxation; landing or failing never affects the path + BetUnknown DependencyBetType = "" // zero-value sentinel; never valid + BetIncluded DependencyBetType = "included" // bet it lands; head built on top of it, refuted if it fails + BetExcluded DependencyBetType = "excluded" // bet it does not land; refuted if it lands + BetDropped DependencyBetType = "dropped" // dropped by relaxation; landing or failing never affects the path ) // SpeculationPathSet is one head's chosen paths — live and recently finished — @@ -148,10 +148,10 @@ const ( // collide with an old build; live heads are listed via the batch store's // by-state query. Every logical path is self-describing, but a store may encode // the common head and ordered dependency IDs once per set and store each path's -// bets positionally — two bits per dependency, or a base-3 code the depth bound -// keeps to a small integer. +// bets positionally — two bits per dependency, or a base-3 code that stays a +// small integer. type SpeculationPathSet struct { - BatchID string // primary key; the head batch + Head string // primary key; ID of the head batch Paths []SpeculationPathEntry // the head's chosen paths Version int // for compare-and-swap writes } @@ -170,17 +170,17 @@ type Speculator interface { // Speculation is one proposed action on one path; a kept path has no entry. type Speculation struct { Path entity.SpeculationPath // the path acted on; its ID hashes head batch ID + its bets - Action PathAction // Build | Cancel + Action PathAction // build | cancel } // The only two actions the Speculator may propose. No Merge or Fail — // verdicts are the controller's. -type PathAction int +type PathAction string const ( - PathActionUnknown PathAction = 0 // zero-value sentinel; never valid, rejected by the controller's check step - Build PathAction = 1 // start (or resurrect) a build for the path - Cancel PathAction = 2 // preempt this in-flight path (refutation cancels are the controller's) + PathActionUnknown PathAction = "" // zero-value sentinel; never valid, rejected by the controller's check step + PathActionBuild PathAction = "build" // start (or resurrect) a build for the path + PathActionCancel PathAction = "cancel" // preempt this in-flight path (refutation cancels are the controller's) ) ``` @@ -189,12 +189,12 @@ const ( // lazily; the producer computes only what is pulled. type Generator interface { // Open starts the queue's candidate stream, best-first across Speculating heads. - Open(ctx context.Context, batches []entity.Batch, pathSets []entity.SpeculationPathSet) (PathIterator, error) + Open(ctx context.Context, batches []entity.Batch) (PathIterator, error) } type PathIterator interface { // Next yields the next-best candidate across Speculating heads; ok = false means the - // queue's coherent, depth-capped space is exhausted. Candidates descend in + // queue's coherent space is exhausted. Candidates descend in // ranking score, never repeat, and never contradict a resolved fact. Next(ctx context.Context) (c CandidatePath, ok bool, err error) } @@ -208,7 +208,8 @@ type CandidatePath struct { ```go // Allocator — spends the build budget over the iterator, matching in-flight -// paths to the funded set by ID. Budget and clock are injected at construction. +// paths to the funded set by ID and skipping candidates whose path is already +// terminal in the path sets. Budget and clock are injected at construction. type Allocator interface { Allocate(ctx context.Context, pathSets []entity.SpeculationPathSet, iter PathIterator) ([]Speculation, error) } diff --git a/submitqueue/entity/BUILD.bazel b/submitqueue/entity/BUILD.bazel index 2ffad326..964b7ee9 100644 --- a/submitqueue/entity/BUILD.bazel +++ b/submitqueue/entity/BUILD.bazel @@ -20,6 +20,7 @@ go_library( "request_history.go", "request_log.go", "request_summary.go", + "speculation.go", ], importpath = "github.com/uber/submitqueue/submitqueue/entity", visibility = ["//visibility:public"], @@ -38,6 +39,7 @@ go_test( "land_test.go", "request_log_test.go", "request_test.go", + "speculation_test.go", ], embed = [":go_default_library"], deps = [ diff --git a/submitqueue/entity/speculation.go b/submitqueue/entity/speculation.go new file mode 100644 index 00000000..6ed3f558 --- /dev/null +++ b/submitqueue/entity/speculation.go @@ -0,0 +1,196 @@ +// 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 entity + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +// DependencyBetType is how a path treats one of its head's dependencies. +type DependencyBetType string + +const ( + // BetUnknown is the zero-value sentinel; it is never a valid bet. + BetUnknown DependencyBetType = "" + // BetIncluded bets the dependency lands: the head is built on top of it, and + // the path is invalidated if the dependency ultimately fails. + BetIncluded DependencyBetType = "included" + // BetExcluded bets the dependency does not land: the head is built without it, + // and the path is invalidated if the dependency ultimately lands. + BetExcluded DependencyBetType = "excluded" + // BetDropped marks a dependency ignored by conflict relaxation: whether it + // lands or fails never affects the path — it neither gates the merge nor + // invalidates the path. + BetDropped DependencyBetType = "dropped" +) + +// DependencyBet is a path's bet on one dependency of its head. +type DependencyBet struct { + // Batch is the dependency batch ID this bet is about. + Batch string + // Bet is how the path treats the dependency (included, excluded, or dropped). + Bet DependencyBetType +} + +// SpeculationPath is one guess at how a batch's dependencies resolve: a head +// batch plus a bet on each of its dependencies. Every dependency of the head +// appears exactly once, in queue order, so the path is self-describing — its +// full meaning can be read without consulting any external relaxed set or +// dependency list. +type SpeculationPath struct { + // Head is the ID of the batch being built along this path. + Head string + // Bets is one bet per dependency of Head, in queue order. + Bets []DependencyBet +} + +// ID returns the path's stable identity: a hex-encoded SHA-256 over the head +// and its bets in order. Two paths with the same head and the same ordered +// bets share an ID; any difference in head, dependency, or bet yields a +// different ID. The hash is computed on every call and never cached, so a +// caller that compares IDs repeatedly should keep the result. +func (p SpeculationPath) ID() string { + var b strings.Builder + b.WriteString(p.Head) + for _, bet := range p.Bets { + b.WriteByte('\n') + b.WriteString(bet.Batch) + b.WriteByte('=') + b.WriteString(string(bet.Bet)) + } + sum := sha256.Sum256([]byte(b.String())) + return hex.EncodeToString(sum[:]) +} + +// SpeculationPathStatus is the lifecycle status of one speculation path's +// current build attempt. +type SpeculationPathStatus string + +const ( + // SpeculationPathStatusUnknown is the zero-value sentinel; it should never be + // seen in the system. + SpeculationPathStatusUnknown SpeculationPathStatus = "" + // SpeculationPathStatusPending indicates the path is funded under the build + // budget but its build has not started yet. + SpeculationPathStatusPending SpeculationPathStatus = "pending" + // SpeculationPathStatusBuilding indicates the path's build is running in the + // build system. + SpeculationPathStatusBuilding SpeculationPathStatus = "building" + // SpeculationPathStatusPassed indicates the path's build completed + // successfully. This is a terminal state. + SpeculationPathStatusPassed SpeculationPathStatus = "passed" + // SpeculationPathStatusFailed indicates the path's build did not complete + // successfully. This is a terminal state. + SpeculationPathStatusFailed SpeculationPathStatus = "failed" + // SpeculationPathStatusCancelling is the non-terminal intent state set when + // the path's build is being cancelled. The build holds its slot until it + // reaches a terminal state. + SpeculationPathStatusCancelling SpeculationPathStatus = "cancelling" + // SpeculationPathStatusCancelled indicates the path's build was cancelled. + // This is a terminal state. + SpeculationPathStatusCancelled SpeculationPathStatus = "cancelled" +) + +// IsTerminal returns true if the status represents a final state +// (Passed, Failed, or Cancelled). Cancelling is intentionally excluded: it is +// a non-terminal intent, and the build may still reach Passed or Failed before +// it reaches Cancelled. +func (s SpeculationPathStatus) IsTerminal() bool { + switch s { + case SpeculationPathStatusPassed, SpeculationPathStatusFailed, SpeculationPathStatusCancelled: + return true + default: + return false + } +} + +// SpeculationPathEntry is the stored record of one chosen speculation path, +// keyed by the hash of its content. It holds no build reference (that lives on +// the separate execution record, keyed by (ID, Attempt)) and no score (a score +// is meaningful only within a single speculation run). +type SpeculationPathEntry struct { + // ID is the primary key: the hash of the path's content (head plus its + // bets). It always equals Path.ID() — it is materialized here, rather than + // recomputed from Path, because a stored record carries its own key: lookups + // and comparisons read it without rehashing the path. + ID string + // Path is the head plus one bet per dependency, in queue order. + Path SpeculationPath + // Status is the lifecycle status of the current build attempt. + Status SpeculationPathStatus + // Attempt is the build attempt number for this path, starting at 1. A path + // can be built more than once (e.g. after a prior build is cancelled to free + // budget, or fails and is retried), so ID alone does not identify an + // execution — (ID, Attempt) does. It increments with each new build. + Attempt int + // Version is the version of the object. It is used for optimistic locking. + // Versioning starts at 1 and is incremented for each change to the object. + Version int32 + // CreatedAtMs is the creation time in Unix epoch milliseconds. + CreatedAtMs int64 + // UpdatedAtMs is the last-update time in Unix epoch milliseconds. + UpdatedAtMs int64 +} + +// SpeculationPathSet is one head's chosen speculation paths under a single +// version. It holds both live paths and recently finished ones — finished +// entries linger briefly so that a re-run cannot collide with an old build. +// Every path in the set shares the same head and bets over the same ordered +// dependency list. +type SpeculationPathSet struct { + // Head is the primary key: the ID of the head batch these paths speculate + // on. Every path in the set carries this same head. + Head string + // Paths is the head's chosen paths, live and recently finished. + Paths []SpeculationPathEntry + // Version is the version of the object. It is used for optimistic locking. + // Versioning starts at 1 and is incremented for each change to the object. + Version int32 +} + +// PathAction is an action proposed on a speculation path. The set is limited to +// build and cancel; there is no merge or fail action, because a batch's verdict +// is a controller-owned fact, not a proposed action. +type PathAction string + +const ( + // PathActionUnknown is the zero-value sentinel; it is never a valid action. + PathActionUnknown PathAction = "" + // PathActionBuild proposes starting (or resurrecting) a build for the path. + PathActionBuild PathAction = "build" + // PathActionCancel proposes preempting an in-flight path to free build budget. + PathActionCancel PathAction = "cancel" +) + +// Speculation is one proposed action on one path. A path left as-is has no +// Speculation. +type Speculation struct { + // Path is the path the action applies to; its ID hashes the head and its bets. + Path SpeculationPath + // Action is the proposed action (build or cancel). + Action PathAction +} + +// CandidatePath is a path paired with the transient ranking score assigned to it +// within a single speculation run. The ranking score orders candidates for that +// run only and is never stored — rankings go stale across runs. +type CandidatePath struct { + // Path is the candidate: a head plus one bet per dependency. + Path SpeculationPath + // RankingScore is the ordering score assigned this run; higher sorts first. + RankingScore float64 +} diff --git a/submitqueue/entity/speculation_test.go b/submitqueue/entity/speculation_test.go new file mode 100644 index 00000000..e9d93353 --- /dev/null +++ b/submitqueue/entity/speculation_test.go @@ -0,0 +1,148 @@ +// 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 entity + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSpeculationPath_ID_Deterministic(t *testing.T) { + path := SpeculationPath{ + Head: "queueA/batch/3", + Bets: []DependencyBet{ + {Batch: "queueA/batch/1", Bet: BetIncluded}, + {Batch: "queueA/batch/2", Bet: BetExcluded}, + }, + } + + // Same content produces the same ID across separate value instances. + same := SpeculationPath{ + Head: "queueA/batch/3", + Bets: []DependencyBet{ + {Batch: "queueA/batch/1", Bet: BetIncluded}, + {Batch: "queueA/batch/2", Bet: BetExcluded}, + }, + } + + assert.Equal(t, path.ID(), same.ID()) + assert.NotEmpty(t, path.ID()) +} + +func TestSpeculationPath_ID_Sensitivity(t *testing.T) { + base := SpeculationPath{ + Head: "queueA/batch/3", + Bets: []DependencyBet{ + {Batch: "queueA/batch/1", Bet: BetIncluded}, + {Batch: "queueA/batch/2", Bet: BetIncluded}, + }, + } + + tests := []struct { + name string + path SpeculationPath + }{ + { + name: "different head", + path: SpeculationPath{ + Head: "queueA/batch/4", + Bets: base.Bets, + }, + }, + { + name: "different bet type", + path: SpeculationPath{ + Head: base.Head, + Bets: []DependencyBet{ + {Batch: "queueA/batch/1", Bet: BetIncluded}, + {Batch: "queueA/batch/2", Bet: BetExcluded}, + }, + }, + }, + { + name: "different dependency", + path: SpeculationPath{ + Head: base.Head, + Bets: []DependencyBet{ + {Batch: "queueA/batch/1", Bet: BetIncluded}, + {Batch: "queueA/batch/9", Bet: BetIncluded}, + }, + }, + }, + { + name: "different bet order", + path: SpeculationPath{ + Head: base.Head, + Bets: []DependencyBet{ + {Batch: "queueA/batch/2", Bet: BetIncluded}, + {Batch: "queueA/batch/1", Bet: BetIncluded}, + }, + }, + }, + { + name: "no bets", + path: SpeculationPath{ + Head: base.Head, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.NotEqual(t, base.ID(), tt.path.ID()) + }) + } +} + +func TestSpeculationPathStatus_IsTerminal(t *testing.T) { + tests := []struct { + name string + status SpeculationPathStatus + expected bool + }{ + {name: "passed is terminal", status: SpeculationPathStatusPassed, expected: true}, + {name: "failed is terminal", status: SpeculationPathStatusFailed, expected: true}, + {name: "cancelled is terminal", status: SpeculationPathStatusCancelled, expected: true}, + {name: "pending is not terminal", status: SpeculationPathStatusPending, expected: false}, + {name: "building is not terminal", status: SpeculationPathStatusBuilding, expected: false}, + {name: "cancelling is not terminal", status: SpeculationPathStatusCancelling, expected: false}, + {name: "unknown is not terminal", status: SpeculationPathStatusUnknown, expected: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.status.IsTerminal()) + }) + } +} + +func TestSpeculationPathEntry_IDDerivesFromPath(t *testing.T) { + path := SpeculationPath{ + Head: "queueA/batch/2", + Bets: []DependencyBet{{Batch: "queueA/batch/1", Bet: BetIncluded}}, + } + + // A well-formed entry keys itself by its path's content hash. + entry := SpeculationPathEntry{ID: path.ID(), Path: path} + assert.Equal(t, entry.Path.ID(), entry.ID) + + // A different path (same head, different bet) is a different entry. + other := SpeculationPath{ + Head: path.Head, + Bets: []DependencyBet{{Batch: "queueA/batch/1", Bet: BetExcluded}}, + } + assert.NotEqual(t, entry.ID, other.ID()) +}