diff --git a/Makefile b/Makefile index 5aeff768..dfc804b7 100644 --- a/Makefile +++ b/Makefile @@ -364,7 +364,7 @@ local-stovepipe-stop: ## Stop the Stovepipe service mocks: ## Generate mock files using mockgen @echo "Generating mocks..." - @$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/pusher/... ./submitqueue/extension/scorer/... ./submitqueue/extension/conflict/... ./platform/consumer/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/... + @$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/pusher/... ./submitqueue/extension/scorer/... ./submitqueue/extension/conflict/... ./submitqueue/extension/speculation/enumerator/... ./submitqueue/extension/speculation/dependencylimit/... ./submitqueue/extension/speculation/scorer/... ./submitqueue/extension/speculation/selector/... ./submitqueue/extension/speculation/selectionlimit/... ./submitqueue/extension/prioritization/prioritizer/... ./submitqueue/extension/prioritization/limit/... ./platform/consumer/... ./submitqueue/core/changeset/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/... @echo "Mocks generated successfully!" proto: ## Generate protobuf files from .proto definitions diff --git a/submitqueue/entity/build.go b/submitqueue/entity/build.go index 86039f43..2d8db24e 100644 --- a/submitqueue/entity/build.go +++ b/submitqueue/entity/build.go @@ -53,12 +53,6 @@ func (s BuildStatus) IsTerminal() bool { return s == BuildStatusSucceeded || s == BuildStatusFailed || s == BuildStatusCancelled } -// SpeculationPathInfo represents the base and head commits of a speculation path used in a build. -type SpeculationPathInfo struct { - // Base is a list of batchIDs(in order) that form the base of this speculation path. - Base []string -} - // Build represents a build scheduled for a batch along a specific speculation path. // All fields except the Status are immutable after creation. type Build struct { @@ -69,10 +63,9 @@ type Build struct { BatchID string // SpeculationPath is the speculation path that represents this build. For // a given batch this path is crafted from the graph that is generated from the - // dependencies of this batch. - SpeculationPath SpeculationPathInfo - // Score represents the build prediction score for this speculation path. - Score float32 + // dependencies of this batch. Its Head is the batch being verified (equal to + // BatchID) and its Base is the assumed-good prefix of predecessor batches. + SpeculationPath SpeculationPath // Status represents the state of the build lifecycle this build is in. Status BuildStatus } diff --git a/submitqueue/entity/build_test.go b/submitqueue/entity/build_test.go index 97de0193..755443d4 100644 --- a/submitqueue/entity/build_test.go +++ b/submitqueue/entity/build_test.go @@ -70,10 +70,10 @@ func TestBuild_ToBytes(t *testing.T) { build := Build{ ID: "build-1", BatchID: "batch-1", - SpeculationPath: SpeculationPathInfo{ + SpeculationPath: SpeculationPath{ Base: []string{"batch-0", "batch-prev"}, + Head: "batch-1", }, - Score: 0.85, Status: BuildStatusAccepted, } @@ -92,10 +92,10 @@ func TestBuildFromBytes(t *testing.T) { original := Build{ ID: "build-42", BatchID: "batch-7", - SpeculationPath: SpeculationPathInfo{ + SpeculationPath: SpeculationPath{ Base: []string{"batch-5", "batch-6"}, + Head: "batch-7", }, - Score: 0.92, Status: BuildStatusAccepted, } @@ -111,7 +111,6 @@ func TestBuildFromBytes(t *testing.T) { assert.Equal(t, original.ID, deserialized.ID) assert.Equal(t, original.BatchID, deserialized.BatchID) assert.Equal(t, original.SpeculationPath.Base, deserialized.SpeculationPath.Base) - assert.Equal(t, original.Score, deserialized.Score) assert.Equal(t, original.Status, deserialized.Status) } @@ -132,7 +131,6 @@ func TestBuildFromBytes_EmptyData(t *testing.T) { assert.Empty(t, build.ID) assert.Empty(t, build.BatchID) assert.Equal(t, BuildStatusUnknown, build.Status) - assert.Equal(t, float32(0), build.Score) } func TestBuild_SerializationRoundTrip(t *testing.T) { @@ -145,10 +143,10 @@ func TestBuild_SerializationRoundTrip(t *testing.T) { build: Build{ ID: "build-100", BatchID: "batch-50", - SpeculationPath: SpeculationPathInfo{ + SpeculationPath: SpeculationPath{ Base: []string{"batch-48", "batch-49"}, + Head: "batch-50", }, - Score: 0.75, Status: BuildStatusAccepted, }, }, @@ -157,19 +155,18 @@ func TestBuild_SerializationRoundTrip(t *testing.T) { build: Build{ ID: "build-200", BatchID: "batch-60", - Score: 1.0, Status: BuildStatusSucceeded, }, }, { - name: "failed build with zero score", + name: "failed build", build: Build{ ID: "build-300", BatchID: "batch-70", - SpeculationPath: SpeculationPathInfo{ + SpeculationPath: SpeculationPath{ Base: []string{"batch-65"}, + Head: "batch-70", }, - Score: 0, Status: BuildStatusFailed, }, }, diff --git a/submitqueue/entity/speculation_tree.go b/submitqueue/entity/speculation_tree.go index d6bb1750..ee33e311 100644 --- a/submitqueue/entity/speculation_tree.go +++ b/submitqueue/entity/speculation_tree.go @@ -14,38 +14,120 @@ package entity -// SpeculationPathAction defines the possible actions for a speculation path. +// SpeculationPath is a single speculation path: an assumed-good prefix of +// predecessor batches (Base) on top of which the batch under verification +// (Head) is built and validated. +// +// This is the unit the build stage consumes: Base maps to the build runner's +// base changes (an assumed-good prefix to apply) and Head maps to the changes +// being validated. +type SpeculationPath struct { + // Base is the ordered list of predecessor batch IDs assumed to have passed. + // Empty means the path builds the head batch directly on the target branch. + Base []string + // Head is the batch ID being verified by this path. + Head string +} + +// SpeculationPathStatus is the observed lifecycle state of a speculation path. +// It is written only by the orchestrator's speculate controller (into the +// speculation tree store) and read by the path selector as input; enumerators +// and selectors never write it. +type SpeculationPathStatus string + +const ( + // SpeculationPathStatusUnknown is the unreachable zero value, set by default + // on init. A persisted path always carries a real status (candidate onward), + // so this should never be seen in the store. + SpeculationPathStatusUnknown SpeculationPathStatus = "" + // SpeculationPathStatusCandidate is a freshly enumerated path the controller + // has persisted but not yet sent to build. + SpeculationPathStatusCandidate SpeculationPathStatus = "candidate" + // SpeculationPathStatusSelected is a path the controller has sent to the build + // controller (in response to a selector Build action) but for which no build + // signal has arrived yet — the build system may not have started it + // (resource-gated), so whether it is actually building is not yet known. + SpeculationPathStatusSelected SpeculationPathStatus = "selected" + // SpeculationPathStatusBuilding is a path a build signal has confirmed is in + // flight; its BuildID is known. + SpeculationPathStatusBuilding SpeculationPathStatus = "building" + // SpeculationPathStatusPassed is a path whose build succeeded. + SpeculationPathStatusPassed SpeculationPathStatus = "passed" + // SpeculationPathStatusFailed is a path whose build failed. + SpeculationPathStatusFailed SpeculationPathStatus = "failed" + // SpeculationPathStatusCancelled is a path that is no longer pursued — its + // base was invalidated, its build was cancelled, or the selector dropped it. + SpeculationPathStatusCancelled SpeculationPathStatus = "cancelled" +) + +// SpeculationPathAction is the action a path selector asks the controller to +// take for a path. It is the selector's only output: ephemeral (recomputed +// every time the selector runs) and never persisted. The controller enacts it +// and records the resulting SpeculationPathStatus. type SpeculationPathAction string const ( - // SpeculationPathActionUnknown is the default zero value for SpeculationPathAction. + // SpeculationPathActionUnknown is the unreachable zero value. A real decision + // always carries Build or Cancel; the selector expresses "leave this path + // as-is" by omitting it from its decisions, not by returning this. SpeculationPathActionUnknown SpeculationPathAction = "" - // TODO: Add comprehensive list of actions + // SpeculationPathActionBuild asks the controller to send this path to the + // build controller (which triggers a build subject to resources). The path moves + // to Selected on send, then Building once a build signal confirms it. + SpeculationPathActionBuild SpeculationPathAction = "build" + // SpeculationPathActionCancel asks the controller to drop this path and + // cancel any build in flight for it. + SpeculationPathActionCancel SpeculationPathAction = "cancel" ) -// SpeculationInfo represents metadata about a single speculation path, including the path through the dependency graph, its current state, and the predicted build score. -type SpeculationInfo struct { - // Path represents the speculation path; which is an ordered list of batches. - Path []string - // Action is a state that this path is in. - Action SpeculationPathAction - // Score is score for this speculation path. +// SpeculationPathInfo is the per-path entry in a speculation tree: a path, its +// latest predicted-success score, its controller-owned status, and a link to +// the build dispatched for it (if any). +type SpeculationPathInfo struct { + // Path is the Base/Head split this entry covers. + Path SpeculationPath + // Score is the path's predicted-success score. It is computed by the scorer + // and persisted by the controller, not set at enumeration — the enumerator + // produces structure only. It is dynamic: the controller re-runs the scorer + // on every respeculate (as dependencies land, dependency builds pass, or + // sibling paths fail), so the value tracks the latest state rather than a + // figure frozen when the path was first enumerated (~0 until the first pass). Score float32 + // Status is the observed lifecycle state of the path. Written only by the + // controller; read by the selector. + Status SpeculationPathStatus + // BuildID links this path to its build. Empty until a build signal confirms + // the build and the controller records it (Selected -> Building); the + // controller never knows the ID at send time. + BuildID string +} + +// SpeculationPathDecision is a path selector's decision for a single path: the +// action the controller should take for it. It is the selector's output and is +// not persisted. +type SpeculationPathDecision struct { + // Path identifies the speculation path the action applies to. + Path SpeculationPath + // Action is what the controller should do for the path. + Action SpeculationPathAction } -// SpeculationTree represents the set of speculation paths constructed for a batch based on its dependency graph. +// SpeculationTree is the set of candidate speculation paths for a batch, built +// from its dependency graph. type SpeculationTree struct { // BatchID is the batch for which this speculation tree is constructed. BatchID string - // Speculations is a list of speculation paths for this batch based on a graph of its - // dependents. + // Paths is the candidate speculation paths for this batch, derived from a + // graph of its dependencies. Each entry's per-path dynamic state (Score, + // Status, BuildID) is documented on SpeculationPathInfo. // // For e.g - Consider batches - queueA/batch/1, queueA/batch/2, queueA/batch/3 - // such that - queueA/batch/2 and queueA/batch/3 depend on queueA/batch/1 + // such that - queueA/batch/2 and queueA/batch/3 depend on queueA/batch/1. + // Each dependent batch gets two paths: build alone, or build on the + // assumed-good predecessor. Just after enumeration every path is a candidate: // - // Speculations for queueA/batch/1 - [{Path: []string{"queueA/batch/1"}, State: "scheduled", Score: 0.1}] - // Speculations for queueA/batch/2 - [{Path: []string{"queueA/batch/2"}, State: "scheduled", Score: 0.9}, {Path: []string{"queueA/batch/1", "queueA/batch/2"}, State: "scheduled", Score: 0.3}] - // Speculations for queueA/batch/3 - [{Path: []string{"queueA/batch/3"}, State: "scheduled", Score: 0.9}, {Path: []string{"queueA/batch/1", "queueA/batch/3"}, State: "scheduled", Score: 0.3}] + // Paths for queueA/batch/2 - [{Path: {Base: [], Head: "queueA/batch/2"}, Status: "candidate"}, {Path: {Base: ["queueA/batch/1"], Head: "queueA/batch/2"}, Status: "candidate"}] + // Paths for queueA/batch/3 - [{Path: {Base: [], Head: "queueA/batch/3"}, Status: "candidate"}, {Path: {Base: ["queueA/batch/1"], Head: "queueA/batch/3"}, Status: "candidate"}] // - Speculations []SpeculationInfo + Paths []SpeculationPathInfo } diff --git a/submitqueue/extension/prioritization/limit/BUILD.bazel b/submitqueue/extension/prioritization/limit/BUILD.bazel new file mode 100644 index 00000000..e68facf0 --- /dev/null +++ b/submitqueue/extension/prioritization/limit/BUILD.bazel @@ -0,0 +1,8 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["limit.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/prioritization/limit", + visibility = ["//visibility:public"], +) diff --git a/submitqueue/extension/prioritization/limit/README.md b/submitqueue/extension/prioritization/limit/README.md new file mode 100644 index 00000000..8859a6c6 --- /dev/null +++ b/submitqueue/extension/prioritization/limit/README.md @@ -0,0 +1,17 @@ +# Prioritization Limit + +Vendor-agnostic "how much" policy that bounds how many builds a queue may run at once — the queue's concurrent-build budget. + +See the [Speculation RFC](../../../../doc/rfc/submitqueue/speculation.md) for the end-to-end design and how limits fit into the two-layer speculation model. + +## Prioritization Limit + +The prioritization limit is the [prioritizer](../prioritizer)'s companion. The prioritizer decides **which** of the queue's pending builds run — its ranking across all in-flight batches; the prioritization limit decides **how many** fit at once. It is the queue-wide resource knob, the ultimate cap on speculation's demand on CI. + +The value is **signal-driven**, not a fixed constant. Its primary input is the build system's available capacity, but a policy may also weigh cost budgets, time of day, or an experiment toggle. + +It is **injected into the prioritizer** at construction and called by it, never passed as a method parameter — following the repo's extension-contract pattern, keeping the prioritizer interface limit-free and stable, and letting the limit be swapped independently of prioritizer logic. + +## Factory + +A per-queue factory returns the limit policy for a queue, following the repo's extension contract. It is handed only the queue identity; the signals a policy weighs — a capacity feed, cost budgets, config — are injected at construction by the integrator in the wiring layer, which is also where the limit is handed to the prioritizer. Computing the limit itself takes no further inputs. diff --git a/submitqueue/extension/prioritization/limit/fake/BUILD.bazel b/submitqueue/extension/prioritization/limit/fake/BUILD.bazel new file mode 100644 index 00000000..00743855 --- /dev/null +++ b/submitqueue/extension/prioritization/limit/fake/BUILD.bazel @@ -0,0 +1,19 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/prioritization/limit/fake", + visibility = ["//visibility:public"], + deps = ["//submitqueue/extension/prioritization/limit:go_default_library"], +) + +go_test( + name = "go_default_test", + srcs = ["fake_test.go"], + embed = [":go_default_library"], + deps = [ + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/submitqueue/extension/prioritization/limit/fake/fake.go b/submitqueue/extension/prioritization/limit/fake/fake.go new file mode 100644 index 00000000..d612fefa --- /dev/null +++ b/submitqueue/extension/prioritization/limit/fake/fake.go @@ -0,0 +1,52 @@ +// 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 fake provides a programmable limit.PrioritizationLimit for tests and +// examples. New sets the value returned by Limit; FailWith injects an error on +// every call. It is intended for examples and tests only, never production. +package fake + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/extension/prioritization/limit" +) + +// PrioritizationLimit is a programmable limit.PrioritizationLimit. +type PrioritizationLimit struct { + limit int + err error +} + +// New returns a fake PrioritizationLimit whose Limit returns the given value. +func New(value int) *PrioritizationLimit { + return &PrioritizationLimit{limit: value} +} + +// FailWith makes every Limit call return err. +func (l *PrioritizationLimit) FailWith(err error) *PrioritizationLimit { + l.err = err + return l +} + +// Limit returns the configured value, or the injected error if FailWith was set. +func (l *PrioritizationLimit) Limit(_ context.Context) (int, error) { + if l.err != nil { + return 0, l.err + } + return l.limit, nil +} + +// ensure the fake satisfies the interface. +var _ limit.PrioritizationLimit = (*PrioritizationLimit)(nil) diff --git a/submitqueue/extension/prioritization/limit/fake/fake_test.go b/submitqueue/extension/prioritization/limit/fake/fake_test.go new file mode 100644 index 00000000..f49fdcb0 --- /dev/null +++ b/submitqueue/extension/prioritization/limit/fake/fake_test.go @@ -0,0 +1,36 @@ +// 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 fake + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLimit_ReturnsConfiguredValue(t *testing.T) { + got, err := New(8).Limit(context.Background()) + require.NoError(t, err) + assert.Equal(t, 8, got) +} + +func TestLimit_FailWith(t *testing.T) { + sentinel := errors.New("boom") + _, err := New(8).FailWith(sentinel).Limit(context.Background()) + require.ErrorIs(t, err, sentinel) +} diff --git a/submitqueue/extension/prioritization/limit/limit.go b/submitqueue/extension/prioritization/limit/limit.go new file mode 100644 index 00000000..ed87b33c --- /dev/null +++ b/submitqueue/extension/prioritization/limit/limit.go @@ -0,0 +1,57 @@ +// 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 limit + +//go:generate mockgen -source=limit.go -destination=mock/limit_mock.go -package=mock + +import "context" + +// PrioritizationLimit is the "how much" policy that bounds how many builds a +// queue may run at once — the queue's concurrent-build budget. +// +// It is the prioritizer's companion: the prioritizer decides *which* of the +// queue's pending builds run (its ranking across all in-flight batches); the +// prioritization limit decides *how many* fit at once. It is the queue-wide +// resource knob, the ultimate cap on speculation's demand on CI. +// +// The value is dynamic: it may change between calls, so the prioritizer reads it +// each round rather than caching it. +// +// It is injected into the prioritizer at construction and called by it, never +// passed as a method parameter, keeping the prioritizer interface limit-free and +// stable. +type PrioritizationLimit interface { + // Limit returns the current maximum number of concurrent builds for the + // queue. The prioritizer admits at most this many candidates. It takes no + // parameters; anything an implementation needs is injected at construction. + Limit(ctx context.Context) (int, error) +} + +// Config carries the per-queue identity handed to a Factory. The system knows +// only the queue name; everything a policy needs to compute the limit (a +// capacity feed, cost budgets, config) is injected at construction by the +// integrator. +type Config struct { + // QueueName identifies the queue this PrioritizationLimit serves. + QueueName string +} + +// Factory builds the PrioritizationLimit for a queue. Implementations are +// provided by integrators (and tests) and inject whatever signals they need at +// construction. +type Factory interface { + // For returns the PrioritizationLimit for the given queue. + For(cfg Config) (PrioritizationLimit, error) +} diff --git a/submitqueue/extension/prioritization/limit/mock/BUILD.bazel b/submitqueue/extension/prioritization/limit/mock/BUILD.bazel new file mode 100644 index 00000000..4b4f2976 --- /dev/null +++ b/submitqueue/extension/prioritization/limit/mock/BUILD.bazel @@ -0,0 +1,12 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["limit_mock.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/prioritization/limit/mock", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/extension/prioritization/limit:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + ], +) diff --git a/submitqueue/extension/prioritization/limit/mock/limit_mock.go b/submitqueue/extension/prioritization/limit/mock/limit_mock.go new file mode 100644 index 00000000..4184306c --- /dev/null +++ b/submitqueue/extension/prioritization/limit/mock/limit_mock.go @@ -0,0 +1,96 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: limit.go +// +// Generated by this command: +// +// mockgen -source=limit.go -destination=mock/limit_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + limit "github.com/uber/submitqueue/submitqueue/extension/prioritization/limit" + gomock "go.uber.org/mock/gomock" +) + +// MockPrioritizationLimit is a mock of PrioritizationLimit interface. +type MockPrioritizationLimit struct { + ctrl *gomock.Controller + recorder *MockPrioritizationLimitMockRecorder + isgomock struct{} +} + +// MockPrioritizationLimitMockRecorder is the mock recorder for MockPrioritizationLimit. +type MockPrioritizationLimitMockRecorder struct { + mock *MockPrioritizationLimit +} + +// NewMockPrioritizationLimit creates a new mock instance. +func NewMockPrioritizationLimit(ctrl *gomock.Controller) *MockPrioritizationLimit { + mock := &MockPrioritizationLimit{ctrl: ctrl} + mock.recorder = &MockPrioritizationLimitMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPrioritizationLimit) EXPECT() *MockPrioritizationLimitMockRecorder { + return m.recorder +} + +// Limit mocks base method. +func (m *MockPrioritizationLimit) Limit(ctx context.Context) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Limit", ctx) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Limit indicates an expected call of Limit. +func (mr *MockPrioritizationLimitMockRecorder) Limit(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Limit", reflect.TypeOf((*MockPrioritizationLimit)(nil).Limit), ctx) +} + +// MockFactory is a mock of Factory interface. +type MockFactory struct { + ctrl *gomock.Controller + recorder *MockFactoryMockRecorder + isgomock struct{} +} + +// MockFactoryMockRecorder is the mock recorder for MockFactory. +type MockFactoryMockRecorder struct { + mock *MockFactory +} + +// NewMockFactory creates a new mock instance. +func NewMockFactory(ctrl *gomock.Controller) *MockFactory { + mock := &MockFactory{ctrl: ctrl} + mock.recorder = &MockFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { + return m.recorder +} + +// For mocks base method. +func (m *MockFactory) For(cfg limit.Config) (limit.PrioritizationLimit, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "For", cfg) + ret0, _ := ret[0].(limit.PrioritizationLimit) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// For indicates an expected call of For. +func (mr *MockFactoryMockRecorder) For(cfg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockFactory)(nil).For), cfg) +} diff --git a/submitqueue/extension/prioritization/prioritizer/BUILD.bazel b/submitqueue/extension/prioritization/prioritizer/BUILD.bazel new file mode 100644 index 00000000..bd4f8fa3 --- /dev/null +++ b/submitqueue/extension/prioritization/prioritizer/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["prioritizer.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/prioritization/prioritizer", + visibility = ["//visibility:public"], + deps = ["//submitqueue/entity:go_default_library"], +) diff --git a/submitqueue/extension/prioritization/prioritizer/README.md b/submitqueue/extension/prioritization/prioritizer/README.md new file mode 100644 index 00000000..c8f4680c --- /dev/null +++ b/submitqueue/extension/prioritization/prioritizer/README.md @@ -0,0 +1,19 @@ +# Build Prioritizer + +Vendor-agnostic interface for the queue-wide policy that rations a shared build budget across every in-flight batch in a queue. + +See the [Speculation RFC](../../../../doc/rfc/submitqueue/speculation.md) for the end-to-end design and how prioritization fits into the orchestrator pipeline. + +## Why prioritization is not under speculation + +Speculation seams (enumerator, scorer, selector) are **per batch** — they run inside `speculate`, which is partitioned per batch. Prioritization is **queue-wide** and lives at the **build stage**, where all of a queue's selected paths converge and the build budget is known. It is a different vantage point with a different lifetime, so it sits in its own `prioritization/` family rather than under `speculation/`. + +## Prioritizer + +Selection is per batch and blind to other batches, so it cannot ration a shared budget: if every batch selected generously, their combined demand could swamp CI. The prioritizer closes that gap. It sees every selected build across all of the queue's in-flight batches, ranks them by each build's score (plus any fairness or tie-break policy), and admits only the subset that fits the queue's concurrent-build budget. Selection expresses *desire* per batch; prioritization reconciles that desire against *supply* — it is the queue-wide enforcer. + +The **store is the source of truth**, and the prioritizer is bound to its queue at construction — so it takes no arguments. It reads everything it needs for the whole queue from storage through read access injected at its factory: the pending builds and each build's speculation-path score, which serves as the admission priority. It ranks them and returns the admitted subset; it never writes — dispatching the admitted builds is the controller's job. The likely implementation is lightweight: priority-ordered consumption under a concurrency cap makes "top-N by score" emerge naturally; an explicit admit-top-N with preemption or fairness is the fallback when ordering alone is not enough. + +## Factory + +A per-queue factory returns the prioritizer for a queue, following the repo's extension contract. It is handed only the queue identity; read access to the build and tree stores, the prioritization limit, fairness policy, and capacity signals are injected at construction by the integrator in the wiring layer. Prioritization itself stays config-free. diff --git a/submitqueue/extension/prioritization/prioritizer/fake/BUILD.bazel b/submitqueue/extension/prioritization/prioritizer/fake/BUILD.bazel new file mode 100644 index 00000000..64e5a9f2 --- /dev/null +++ b/submitqueue/extension/prioritization/prioritizer/fake/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/prioritization/prioritizer/fake", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/prioritization/prioritizer:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["fake_test.go"], + embed = [":go_default_library"], + deps = [ + "//submitqueue/entity:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/submitqueue/extension/prioritization/prioritizer/fake/fake.go b/submitqueue/extension/prioritization/prioritizer/fake/fake.go new file mode 100644 index 00000000..eac439e7 --- /dev/null +++ b/submitqueue/extension/prioritization/prioritizer/fake/fake.go @@ -0,0 +1,62 @@ +// 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 fake provides a programmable prioritizer.Prioritizer for tests and +// examples. It returns the builds seeded via SetAdmitted (none by default). +// FailWith injects an error on every call. It stands in for a real prioritizer's +// storage reads so tests need no store. It is intended for examples and tests +// only, never production. +package fake + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/prioritization/prioritizer" +) + +// Prioritizer is a programmable prioritizer.Prioritizer. +type Prioritizer struct { + admitted []entity.Build + err error +} + +// New returns a fake Prioritizer that admits nothing. Seed the admitted builds +// with SetAdmitted. +func New() *Prioritizer { + return &Prioritizer{} +} + +// SetAdmitted seeds the builds returned by Prioritize, in the order given. +func (p *Prioritizer) SetAdmitted(builds ...entity.Build) *Prioritizer { + p.admitted = builds + return p +} + +// FailWith makes every Prioritize call return err. +func (p *Prioritizer) FailWith(err error) *Prioritizer { + p.err = err + return p +} + +// Prioritize returns the seeded admitted builds. +func (p *Prioritizer) Prioritize(_ context.Context) ([]entity.Build, error) { + if p.err != nil { + return nil, p.err + } + return p.admitted, nil +} + +// ensure the fake satisfies the interface. +var _ prioritizer.Prioritizer = (*Prioritizer)(nil) diff --git a/submitqueue/extension/prioritization/prioritizer/fake/fake_test.go b/submitqueue/extension/prioritization/prioritizer/fake/fake_test.go new file mode 100644 index 00000000..58eb3ae7 --- /dev/null +++ b/submitqueue/extension/prioritization/prioritizer/fake/fake_test.go @@ -0,0 +1,44 @@ +// 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 fake + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/submitqueue/entity" +) + +func TestPrioritize_DefaultAdmitsNothing(t *testing.T) { + got, err := New().Prioritize(context.Background()) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestPrioritize_ReturnsSeededAdmitted(t *testing.T) { + want := []entity.Build{{ID: "a"}, {ID: "b"}} + got, err := New().SetAdmitted(want...).Prioritize(context.Background()) + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestPrioritize_FailWith(t *testing.T) { + sentinel := errors.New("boom") + _, err := New().FailWith(sentinel).Prioritize(context.Background()) + require.ErrorIs(t, err, sentinel) +} diff --git a/submitqueue/extension/prioritization/prioritizer/mock/BUILD.bazel b/submitqueue/extension/prioritization/prioritizer/mock/BUILD.bazel new file mode 100644 index 00000000..7cd54582 --- /dev/null +++ b/submitqueue/extension/prioritization/prioritizer/mock/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["prioritizer_mock.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/prioritization/prioritizer/mock", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/prioritization/prioritizer:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + ], +) diff --git a/submitqueue/extension/prioritization/prioritizer/mock/prioritizer_mock.go b/submitqueue/extension/prioritization/prioritizer/mock/prioritizer_mock.go new file mode 100644 index 00000000..4db2f4e4 --- /dev/null +++ b/submitqueue/extension/prioritization/prioritizer/mock/prioritizer_mock.go @@ -0,0 +1,97 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: prioritizer.go +// +// Generated by this command: +// +// mockgen -source=prioritizer.go -destination=mock/prioritizer_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + entity "github.com/uber/submitqueue/submitqueue/entity" + prioritizer "github.com/uber/submitqueue/submitqueue/extension/prioritization/prioritizer" + gomock "go.uber.org/mock/gomock" +) + +// MockPrioritizer is a mock of Prioritizer interface. +type MockPrioritizer struct { + ctrl *gomock.Controller + recorder *MockPrioritizerMockRecorder + isgomock struct{} +} + +// MockPrioritizerMockRecorder is the mock recorder for MockPrioritizer. +type MockPrioritizerMockRecorder struct { + mock *MockPrioritizer +} + +// NewMockPrioritizer creates a new mock instance. +func NewMockPrioritizer(ctrl *gomock.Controller) *MockPrioritizer { + mock := &MockPrioritizer{ctrl: ctrl} + mock.recorder = &MockPrioritizerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPrioritizer) EXPECT() *MockPrioritizerMockRecorder { + return m.recorder +} + +// Prioritize mocks base method. +func (m *MockPrioritizer) Prioritize(ctx context.Context) ([]entity.Build, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Prioritize", ctx) + ret0, _ := ret[0].([]entity.Build) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Prioritize indicates an expected call of Prioritize. +func (mr *MockPrioritizerMockRecorder) Prioritize(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Prioritize", reflect.TypeOf((*MockPrioritizer)(nil).Prioritize), ctx) +} + +// MockFactory is a mock of Factory interface. +type MockFactory struct { + ctrl *gomock.Controller + recorder *MockFactoryMockRecorder + isgomock struct{} +} + +// MockFactoryMockRecorder is the mock recorder for MockFactory. +type MockFactoryMockRecorder struct { + mock *MockFactory +} + +// NewMockFactory creates a new mock instance. +func NewMockFactory(ctrl *gomock.Controller) *MockFactory { + mock := &MockFactory{ctrl: ctrl} + mock.recorder = &MockFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { + return m.recorder +} + +// For mocks base method. +func (m *MockFactory) For(cfg prioritizer.Config) (prioritizer.Prioritizer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "For", cfg) + ret0, _ := ret[0].(prioritizer.Prioritizer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// For indicates an expected call of For. +func (mr *MockFactoryMockRecorder) For(cfg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockFactory)(nil).For), cfg) +} diff --git a/submitqueue/extension/prioritization/prioritizer/prioritizer.go b/submitqueue/extension/prioritization/prioritizer/prioritizer.go new file mode 100644 index 00000000..3870f99a --- /dev/null +++ b/submitqueue/extension/prioritization/prioritizer/prioritizer.go @@ -0,0 +1,69 @@ +// 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 prioritizer + +//go:generate mockgen -source=prioritizer.go -destination=mock/prioritizer_mock.go -package=mock + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/entity" +) + +// Prioritizer is the queue-wide policy that rations a shared build budget across +// every in-flight batch in a queue. +// +// Selection is per batch and blind to other batches, so it cannot ration a +// shared budget: if every batch selected generously, their combined demand could +// swamp CI. Prioritization closes that gap. It sees every selected build across +// all of the queue's in-flight batches, ranks them (by each build's score, plus +// any fairness or tie-break policy), and admits only the subset that fits the +// queue's concurrent-build budget. +// +// It lives at the build stage — the one place all of the queue's selected paths +// converge and the build budget is known — not in the per-batch speculate stage. +// It is the queue-wide enforcer: selection expresses desire per batch, +// prioritization reconciles that desire against supply. It is constructed with +// its prioritization limit and applies it itself. +// +// The store is the source of truth, and the prioritizer is bound to its queue at +// construction (Config.QueueName). So it takes no arguments: it reads whatever it +// needs for the whole queue from storage — the pending builds and each build's +// path score — through read access injected at its Factory, ranks them, and +// returns the admitted subset. It never writes; dispatching the admitted builds +// is the controller's job. +type Prioritizer interface { + // Prioritize reads the queue's pending builds and their path scores from + // storage and returns the subset admitted to run now, ranked by score plus any + // fairness policy and capped by the prioritization limit. Builds not returned + // are left pending for a later round. + Prioritize(ctx context.Context) ([]entity.Build, error) +} + +// Config carries the per-queue identity handed to a Factory. The system knows +// only the queue name; everything an implementation needs (read access to the +// build and tree stores, its prioritization limit, fairness policy, capacity +// signals) is injected at construction by the integrator. +type Config struct { + // QueueName identifies the queue this Prioritizer serves. + QueueName string +} + +// Factory builds the Prioritizer for a queue. Implementations are provided by +// integrators (and tests) and inject whatever they need at construction. +type Factory interface { + // For returns the Prioritizer for the given queue. + For(cfg Config) (Prioritizer, error) +} diff --git a/submitqueue/extension/speculation/dependencylimit/BUILD.bazel b/submitqueue/extension/speculation/dependencylimit/BUILD.bazel new file mode 100644 index 00000000..5abae99b --- /dev/null +++ b/submitqueue/extension/speculation/dependencylimit/BUILD.bazel @@ -0,0 +1,8 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["dependencylimit.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit", + visibility = ["//visibility:public"], +) diff --git a/submitqueue/extension/speculation/dependencylimit/README.md b/submitqueue/extension/speculation/dependencylimit/README.md new file mode 100644 index 00000000..0368f2c8 --- /dev/null +++ b/submitqueue/extension/speculation/dependencylimit/README.md @@ -0,0 +1,17 @@ +# Speculation Dependency Limit + +Vendor-agnostic "how much" policy that bounds how many **active** (in-flight, non-terminal) dependencies a batch may speculate over. + +See the [Speculation RFC](../../../../doc/rfc/submitqueue/speculation.md) for the end-to-end design and how limits fit into the two-layer speculation model. + +## Dependency Limit + +Speculation splits into *decision seams* (what to build) and *limit policies* (how much to allow). The dependency limit is the first limit: it is the **eligibility gate** for speculation. A batch becomes eligible to enumerate only when its count of active dependencies is at or below the current limit; otherwise it waits. Nothing is dropped — as dependencies land they leave the active set, the count shrinks, and the batch is admitted. The gate applies even to the fully-stacked happy path, so a very long chain is not speculated in full at once. + +The value is **signal-driven**, not a fixed constant. Its primary input is the build system's available capacity, so a period of CI pressure can shrink how deep the queue speculates, but a policy may also weigh historical pass rates, cost budgets, time of day, or an experiment toggle. Because the value is dynamic, a change to the limit alone — not only a landing dependency or a DAG change — can newly admit a waiting batch. + +Unlike the selection and prioritization limits, the dependency limit is **not injected into a decision seam**. It gates eligibility *before* enumeration and needs active-dependency reconciliation, which is controller orchestration — so the controller holds it, consults it on every respeculate, and applies it, keeping the enumerator pure. + +## Factory + +A per-queue factory returns the limit policy for a queue, following the repo's extension contract. It is handed only the queue identity; the signals a policy weighs — a capacity feed, historical metrics, config — are injected at construction by the integrator in the wiring layer. Computing the limit itself takes no further inputs. diff --git a/submitqueue/extension/speculation/dependencylimit/dependencylimit.go b/submitqueue/extension/speculation/dependencylimit/dependencylimit.go new file mode 100644 index 00000000..b32d47f3 --- /dev/null +++ b/submitqueue/extension/speculation/dependencylimit/dependencylimit.go @@ -0,0 +1,61 @@ +// 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 dependencylimit + +//go:generate mockgen -source=dependencylimit.go -destination=mock/dependencylimit_mock.go -package=mock + +import "context" + +// DependencyLimit is the "how much" policy that bounds how many active +// (in-flight, non-terminal) dependencies a batch may speculate over. +// +// It is the eligibility gate for speculation: a batch becomes eligible to +// enumerate only when its count of active dependencies is at or below the +// current limit; otherwise it waits, and is admitted later as predecessors land +// and leave the active set. The limit is a bound, not a trim — nothing is +// dropped from a batch's base. +// +// The value is dynamic: it may change between calls — not only when a +// dependency lands — so a change alone can newly admit a waiting batch, and the +// controller re-consults it on every respeculate rather than caching it. +// +// This limit is the exception among the speculation limits: it gates eligibility +// *before* enumeration and needs active-dependency reconciliation, which is +// controller orchestration — so the controller holds and applies it, rather than +// it being injected into a decision seam. The enumerator stays pure. +type DependencyLimit interface { + // Limit returns the current maximum number of active dependencies a batch + // may speculate over. The controller compares a batch's active-dependency + // count against this to decide eligibility. It takes no parameters; anything + // an implementation needs is injected at construction. + Limit(ctx context.Context) (int, error) +} + +// Config carries the per-queue identity handed to a Factory. The system knows +// only the queue name; everything a policy needs to compute the limit (a +// capacity feed, historical metrics, config) is injected at construction by the +// integrator. +type Config struct { + // QueueName identifies the queue this DependencyLimit serves. + QueueName string +} + +// Factory builds the DependencyLimit for a queue. Implementations are provided +// by integrators (and tests) and inject whatever signals they need at +// construction. +type Factory interface { + // For returns the DependencyLimit for the given queue. + For(cfg Config) (DependencyLimit, error) +} diff --git a/submitqueue/extension/speculation/dependencylimit/fake/BUILD.bazel b/submitqueue/extension/speculation/dependencylimit/fake/BUILD.bazel new file mode 100644 index 00000000..41a13937 --- /dev/null +++ b/submitqueue/extension/speculation/dependencylimit/fake/BUILD.bazel @@ -0,0 +1,19 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit/fake", + visibility = ["//visibility:public"], + deps = ["//submitqueue/extension/speculation/dependencylimit:go_default_library"], +) + +go_test( + name = "go_default_test", + srcs = ["fake_test.go"], + embed = [":go_default_library"], + deps = [ + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/submitqueue/extension/speculation/dependencylimit/fake/fake.go b/submitqueue/extension/speculation/dependencylimit/fake/fake.go new file mode 100644 index 00000000..3045cfdd --- /dev/null +++ b/submitqueue/extension/speculation/dependencylimit/fake/fake.go @@ -0,0 +1,52 @@ +// 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 fake provides a programmable dependencylimit.DependencyLimit for tests +// and examples. New sets the value returned by Limit; FailWith injects an error +// on every call. It is intended for examples and tests only, never production. +package fake + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit" +) + +// DependencyLimit is a programmable dependencylimit.DependencyLimit. +type DependencyLimit struct { + limit int + err error +} + +// New returns a fake DependencyLimit whose Limit returns the given value. +func New(limit int) *DependencyLimit { + return &DependencyLimit{limit: limit} +} + +// FailWith makes every Limit call return err. +func (l *DependencyLimit) FailWith(err error) *DependencyLimit { + l.err = err + return l +} + +// Limit returns the configured value, or the injected error if FailWith was set. +func (l *DependencyLimit) Limit(_ context.Context) (int, error) { + if l.err != nil { + return 0, l.err + } + return l.limit, nil +} + +// ensure the fake satisfies the interface. +var _ dependencylimit.DependencyLimit = (*DependencyLimit)(nil) diff --git a/submitqueue/extension/speculation/dependencylimit/fake/fake_test.go b/submitqueue/extension/speculation/dependencylimit/fake/fake_test.go new file mode 100644 index 00000000..36bcb690 --- /dev/null +++ b/submitqueue/extension/speculation/dependencylimit/fake/fake_test.go @@ -0,0 +1,36 @@ +// 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 fake + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLimit_ReturnsConfiguredValue(t *testing.T) { + got, err := New(3).Limit(context.Background()) + require.NoError(t, err) + assert.Equal(t, 3, got) +} + +func TestLimit_FailWith(t *testing.T) { + sentinel := errors.New("boom") + _, err := New(3).FailWith(sentinel).Limit(context.Background()) + require.ErrorIs(t, err, sentinel) +} diff --git a/submitqueue/extension/speculation/dependencylimit/mock/BUILD.bazel b/submitqueue/extension/speculation/dependencylimit/mock/BUILD.bazel new file mode 100644 index 00000000..3e928a64 --- /dev/null +++ b/submitqueue/extension/speculation/dependencylimit/mock/BUILD.bazel @@ -0,0 +1,12 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["dependencylimit_mock.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit/mock", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/extension/speculation/dependencylimit:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + ], +) diff --git a/submitqueue/extension/speculation/dependencylimit/mock/dependencylimit_mock.go b/submitqueue/extension/speculation/dependencylimit/mock/dependencylimit_mock.go new file mode 100644 index 00000000..52c743cd --- /dev/null +++ b/submitqueue/extension/speculation/dependencylimit/mock/dependencylimit_mock.go @@ -0,0 +1,96 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: dependencylimit.go +// +// Generated by this command: +// +// mockgen -source=dependencylimit.go -destination=mock/dependencylimit_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + dependencylimit "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit" + gomock "go.uber.org/mock/gomock" +) + +// MockDependencyLimit is a mock of DependencyLimit interface. +type MockDependencyLimit struct { + ctrl *gomock.Controller + recorder *MockDependencyLimitMockRecorder + isgomock struct{} +} + +// MockDependencyLimitMockRecorder is the mock recorder for MockDependencyLimit. +type MockDependencyLimitMockRecorder struct { + mock *MockDependencyLimit +} + +// NewMockDependencyLimit creates a new mock instance. +func NewMockDependencyLimit(ctrl *gomock.Controller) *MockDependencyLimit { + mock := &MockDependencyLimit{ctrl: ctrl} + mock.recorder = &MockDependencyLimitMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDependencyLimit) EXPECT() *MockDependencyLimitMockRecorder { + return m.recorder +} + +// Limit mocks base method. +func (m *MockDependencyLimit) Limit(ctx context.Context) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Limit", ctx) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Limit indicates an expected call of Limit. +func (mr *MockDependencyLimitMockRecorder) Limit(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Limit", reflect.TypeOf((*MockDependencyLimit)(nil).Limit), ctx) +} + +// MockFactory is a mock of Factory interface. +type MockFactory struct { + ctrl *gomock.Controller + recorder *MockFactoryMockRecorder + isgomock struct{} +} + +// MockFactoryMockRecorder is the mock recorder for MockFactory. +type MockFactoryMockRecorder struct { + mock *MockFactory +} + +// NewMockFactory creates a new mock instance. +func NewMockFactory(ctrl *gomock.Controller) *MockFactory { + mock := &MockFactory{ctrl: ctrl} + mock.recorder = &MockFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { + return m.recorder +} + +// For mocks base method. +func (m *MockFactory) For(cfg dependencylimit.Config) (dependencylimit.DependencyLimit, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "For", cfg) + ret0, _ := ret[0].(dependencylimit.DependencyLimit) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// For indicates an expected call of For. +func (mr *MockFactoryMockRecorder) For(cfg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockFactory)(nil).For), cfg) +} diff --git a/submitqueue/extension/speculation/enumerator/BUILD.bazel b/submitqueue/extension/speculation/enumerator/BUILD.bazel new file mode 100644 index 00000000..ad5c3284 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["enumerator.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator", + visibility = ["//visibility:public"], + deps = ["//submitqueue/entity:go_default_library"], +) diff --git a/submitqueue/extension/speculation/enumerator/README.md b/submitqueue/extension/speculation/enumerator/README.md new file mode 100644 index 00000000..9dfe9121 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/README.md @@ -0,0 +1,19 @@ +# Speculation Tree Enumerator + +Vendor-agnostic interface for enumerating the **speculation tree** of a batch — the set of candidate speculation paths the orchestrator may build. + +See the [Speculation RFC](../../../../doc/rfc/submitqueue/speculation.md) for the end-to-end design and how enumeration fits into the orchestrator pipeline. + +## Enumerator + +An enumerator is deliberately **dumb** and purely **structural**: *given a batch and its active dependency batches, it mechanically lists the candidate paths.* It does **not** score paths — that is the [scorer](../scorer)'s job, which the controller re-runs on every respeculate — it does **not** decide which paths to build — that is the [selector](../selector)'s job — it does **not** set path status, and it does **not** decide how far back to speculate. The dependency limit is the controller's responsibility: the controller gates a batch on the limit and hands the enumerator exactly the active dependencies to speculate over, which it then enumerates over verbatim. + +Each candidate is a path: an assumed-good prefix of predecessor batches (the base) on top of which the batch under verification (the head) is built. The base maps directly onto the build stage's base changes and the head onto the changes being validated. + +Enumeration is **pure and deterministic**: the same batch and dependency list always produce the same tree. This lets the controller regenerate a tree whenever the dependency graph changes without tracking incremental state in the enumerator. Keeping enumeration tractable for a very wide dependency list is the enumerator's only real concern. + +The returned paths carry structure only — a Base/Head split, with `Score` and `Status` left unset. The controller stamps `Status` when it persists the tree and calls the scorer to fill `Score`; enumeration produces neither. + +## Factory + +A per-queue factory returns the enumerator for a queue, following the repo's extension contract. It is handed only the queue identity and nothing else; everything an implementation needs — including behavioral knobs like enumeration breadth — is injected at construction by the integrator in the wiring layer, which resolves per-queue settings through `queueconfig`. Enumeration itself stays config-free. diff --git a/submitqueue/extension/speculation/enumerator/enumerator.go b/submitqueue/extension/speculation/enumerator/enumerator.go new file mode 100644 index 00000000..a10b11b3 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/enumerator.go @@ -0,0 +1,63 @@ +// 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 enumerator + +//go:generate mockgen -source=enumerator.go -destination=mock/enumerator_mock.go -package=mock + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/entity" +) + +// Enumerator builds the speculation tree for a batch: the set of candidate +// speculation paths to consider. +// +// Enumeration answers "what futures are possible" for a batch. It is +// deliberately dumb and purely structural: it mechanically lists candidate +// Base/Head paths from the dependency batches it is handed and nothing else. It +// does not score paths — that is the scorer's job (see +// extension/speculation/scorer), which the controller re-runs on every +// respeculate — it does not decide which paths to build — that is the selector's +// job (see extension/speculation/selector) — it does not set path status, and it +// does not decide how far back to speculate: the controller gates on the +// dependency limit and hands Enumerate exactly the active dependencies to +// speculate over. +type Enumerator interface { + // Enumerate returns the speculation tree structure for the batch identified + // by batchID, given its active dependency batches in arrival order. Each + // returned path carries a Base/Head split only: Score and Status are left + // unset — the controller stamps Status on persist and calls the scorer to + // fill Score. + // + // Enumeration is pure and deterministic: the same (batchID, deps) always + // yields the same tree, so callers may regenerate safely. + Enumerate(ctx context.Context, batchID string, deps []entity.Batch) (entity.SpeculationTree, error) +} + +// Config carries the per-queue identity handed to a Factory. The system knows +// only the queue name; everything an implementation needs (including behavioral +// knobs such as speculation depth) is injected at construction by the integrator. +type Config struct { + // QueueName identifies the queue this Enumerator serves. + QueueName string +} + +// Factory builds the Enumerator for a queue. Implementations are provided by +// integrators (and tests) and inject whatever they need at construction. +type Factory interface { + // For returns the Enumerator for the given queue. + For(cfg Config) (Enumerator, error) +} diff --git a/submitqueue/extension/speculation/enumerator/fake/BUILD.bazel b/submitqueue/extension/speculation/enumerator/fake/BUILD.bazel new file mode 100644 index 00000000..14e92890 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/fake/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator/fake", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/speculation/enumerator:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["fake_test.go"], + embed = [":go_default_library"], + deps = [ + "//submitqueue/entity:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/submitqueue/extension/speculation/enumerator/fake/fake.go b/submitqueue/extension/speculation/enumerator/fake/fake.go new file mode 100644 index 00000000..3ed0f7a8 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/fake/fake.go @@ -0,0 +1,65 @@ +// 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 fake provides a programmable in-memory enumerator.Enumerator for tests +// and examples. Seed the tree returned for a batch with Set, keyed by batch ID; +// an unseeded batch enumerates to an empty tree carrying the batch's identity. +// FailWith injects an error on every call to exercise the error path. It is +// intended for examples and tests only, never production. +package fake + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator" +) + +// Enumerator is a programmable in-memory enumerator.Enumerator. +type Enumerator struct { + trees map[string]entity.SpeculationTree + err error +} + +// New returns an empty fake Enumerator. Seed it with Set. +func New() *Enumerator { + return &Enumerator{trees: map[string]entity.SpeculationTree{}} +} + +// Set seeds the tree returned by Enumerate for the given batch ID. +func (e *Enumerator) Set(batchID string, tree entity.SpeculationTree) *Enumerator { + e.trees[batchID] = tree + return e +} + +// FailWith makes every Enumerate call return err. +func (e *Enumerator) FailWith(err error) *Enumerator { + e.err = err + return e +} + +// Enumerate returns the seeded tree for the batch. An unseeded batch returns an +// empty tree carrying the batch's identity. The deps argument is ignored. +func (e *Enumerator) Enumerate(_ context.Context, batchID string, _ []entity.Batch) (entity.SpeculationTree, error) { + if e.err != nil { + return entity.SpeculationTree{}, e.err + } + if tree, ok := e.trees[batchID]; ok { + return tree, nil + } + return entity.SpeculationTree{BatchID: batchID}, nil +} + +// ensure the fake satisfies the interface. +var _ enumerator.Enumerator = (*Enumerator)(nil) diff --git a/submitqueue/extension/speculation/enumerator/fake/fake_test.go b/submitqueue/extension/speculation/enumerator/fake/fake_test.go new file mode 100644 index 00000000..284eb658 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/fake/fake_test.go @@ -0,0 +1,51 @@ +// 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 fake + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/submitqueue/entity" +) + +func TestEnumerate_SeededTree(t *testing.T) { + tree := entity.SpeculationTree{ + BatchID: "q/batch/2", + Paths: []entity.SpeculationPathInfo{ + {Path: entity.SpeculationPath{Head: "q/batch/2"}}, + }, + } + e := New().Set("q/batch/2", tree) + + got, err := e.Enumerate(context.Background(), "q/batch/2", nil) + require.NoError(t, err) + assert.Equal(t, tree, got) +} + +func TestEnumerate_UnseededReturnsEmptyTreeWithID(t *testing.T) { + got, err := New().Enumerate(context.Background(), "q/batch/9", nil) + require.NoError(t, err) + assert.Equal(t, entity.SpeculationTree{BatchID: "q/batch/9"}, got) +} + +func TestEnumerate_FailWith(t *testing.T) { + sentinel := errors.New("boom") + _, err := New().FailWith(sentinel).Enumerate(context.Background(), "q/batch/1", nil) + require.ErrorIs(t, err, sentinel) +} diff --git a/submitqueue/extension/speculation/enumerator/mock/BUILD.bazel b/submitqueue/extension/speculation/enumerator/mock/BUILD.bazel new file mode 100644 index 00000000..c6fab414 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/mock/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["enumerator_mock.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator/mock", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/speculation/enumerator:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + ], +) diff --git a/submitqueue/extension/speculation/enumerator/mock/enumerator_mock.go b/submitqueue/extension/speculation/enumerator/mock/enumerator_mock.go new file mode 100644 index 00000000..6ad1cc75 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/mock/enumerator_mock.go @@ -0,0 +1,97 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: enumerator.go +// +// Generated by this command: +// +// mockgen -source=enumerator.go -destination=mock/enumerator_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + entity "github.com/uber/submitqueue/submitqueue/entity" + enumerator "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator" + gomock "go.uber.org/mock/gomock" +) + +// MockEnumerator is a mock of Enumerator interface. +type MockEnumerator struct { + ctrl *gomock.Controller + recorder *MockEnumeratorMockRecorder + isgomock struct{} +} + +// MockEnumeratorMockRecorder is the mock recorder for MockEnumerator. +type MockEnumeratorMockRecorder struct { + mock *MockEnumerator +} + +// NewMockEnumerator creates a new mock instance. +func NewMockEnumerator(ctrl *gomock.Controller) *MockEnumerator { + mock := &MockEnumerator{ctrl: ctrl} + mock.recorder = &MockEnumeratorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEnumerator) EXPECT() *MockEnumeratorMockRecorder { + return m.recorder +} + +// Enumerate mocks base method. +func (m *MockEnumerator) Enumerate(ctx context.Context, batchID string, deps []entity.Batch) (entity.SpeculationTree, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Enumerate", ctx, batchID, deps) + ret0, _ := ret[0].(entity.SpeculationTree) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Enumerate indicates an expected call of Enumerate. +func (mr *MockEnumeratorMockRecorder) Enumerate(ctx, batchID, deps any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Enumerate", reflect.TypeOf((*MockEnumerator)(nil).Enumerate), ctx, batchID, deps) +} + +// MockFactory is a mock of Factory interface. +type MockFactory struct { + ctrl *gomock.Controller + recorder *MockFactoryMockRecorder + isgomock struct{} +} + +// MockFactoryMockRecorder is the mock recorder for MockFactory. +type MockFactoryMockRecorder struct { + mock *MockFactory +} + +// NewMockFactory creates a new mock instance. +func NewMockFactory(ctrl *gomock.Controller) *MockFactory { + mock := &MockFactory{ctrl: ctrl} + mock.recorder = &MockFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { + return m.recorder +} + +// For mocks base method. +func (m *MockFactory) For(cfg enumerator.Config) (enumerator.Enumerator, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "For", cfg) + ret0, _ := ret[0].(enumerator.Enumerator) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// For indicates an expected call of For. +func (mr *MockFactoryMockRecorder) For(cfg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockFactory)(nil).For), cfg) +} diff --git a/submitqueue/extension/speculation/scorer/BUILD.bazel b/submitqueue/extension/speculation/scorer/BUILD.bazel new file mode 100644 index 00000000..72a50041 --- /dev/null +++ b/submitqueue/extension/speculation/scorer/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["scorer.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/scorer", + visibility = ["//visibility:public"], + deps = ["//submitqueue/entity:go_default_library"], +) diff --git a/submitqueue/extension/speculation/scorer/README.md b/submitqueue/extension/speculation/scorer/README.md new file mode 100644 index 00000000..88aacf38 --- /dev/null +++ b/submitqueue/extension/speculation/scorer/README.md @@ -0,0 +1,17 @@ +# Speculation Path Scorer + +Vendor-agnostic interface for scoring the paths in a batch's **speculation tree** — the predicted-success probability of each candidate bet, recomputed as the batch's world changes. + +See the [Speculation RFC](../../../../doc/rfc/submitqueue/speculation.md) for the end-to-end design and how scoring fits into the orchestrator pipeline. + +## Scorer + +A path's score is a **prediction**: *how likely is this bet to pay off, right now?* The scorer answers it from the current state — the per-batch success probabilities of a path's base batches (`entity.Batch.Score`, set by the score stage), which of those dependencies have already landed or had their build pass (resolved assumptions raise confidence), and optionally other signals such as how long the batch has waited or historical pass rates. The score is the common currency the [selector](../selector) and prioritizer both rank on, so keeping it current is what makes both act on the latest reality. + +Because it is a prediction over live state, the scorer is **re-run on every respeculate**, right after the controller reconciles path status — so when a dependency lands, its build passes, or a sibling path fails, the surviving paths' scores are recomputed before anything is selected or prioritized. The controller drives *when* to rescore (it is part of reconciliation) and persists the result; the scorer owns the *formula*. + +This is the per-**path** scorer, distinct from the per-**batch** [score stage](../../scorer), which sets `entity.Batch.Score`. The path scorer consumes those batch scores to score whole paths. The **store is the source of truth**: the scorer is handed only the batch identity and loads what it needs — the batch's speculation tree (already carrying the controller-reconciled statuses) and its dependency batches — through read access injected at its factory. It never writes: it returns the scored tree and the controller persists the scores, so the controller stays the single writer of tree state. Path structure and status pass through unchanged; only `Score` is recomputed. + +## Factory + +A per-queue factory returns the scorer for a queue, following the repo's extension contract. It is handed only the queue identity; read access to the tree and batch stores, scoring knobs, and any extra signals are injected at construction by the integrator in the wiring layer, which resolves per-queue settings through `queueconfig`. Scoring itself stays config-free. diff --git a/submitqueue/extension/speculation/scorer/fake/BUILD.bazel b/submitqueue/extension/speculation/scorer/fake/BUILD.bazel new file mode 100644 index 00000000..4c573731 --- /dev/null +++ b/submitqueue/extension/speculation/scorer/fake/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/scorer/fake", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/speculation/scorer:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["fake_test.go"], + embed = [":go_default_library"], + deps = [ + "//submitqueue/entity:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/submitqueue/extension/speculation/scorer/fake/fake.go b/submitqueue/extension/speculation/scorer/fake/fake.go new file mode 100644 index 00000000..918bd82f --- /dev/null +++ b/submitqueue/extension/speculation/scorer/fake/fake.go @@ -0,0 +1,66 @@ +// 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 fake provides a programmable scorer.Scorer for tests and examples. +// Seed the scored tree returned for a batch with Set, keyed by batch ID; an +// unseeded batch returns an empty tree carrying the batch's identity. FailWith +// injects an error on every call. It stands in for a real scorer's storage reads +// so tests need no store. It is intended for examples and tests only, never +// production. +package fake + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/speculation/scorer" +) + +// Scorer is a programmable scorer.Scorer. +type Scorer struct { + trees map[string]entity.SpeculationTree + err error +} + +// New returns an empty fake Scorer. Seed it with Set. +func New() *Scorer { + return &Scorer{trees: map[string]entity.SpeculationTree{}} +} + +// Set seeds the scored tree returned by Score for the given batch ID. +func (s *Scorer) Set(batchID string, tree entity.SpeculationTree) *Scorer { + s.trees[batchID] = tree + return s +} + +// FailWith makes every Score call return err. +func (s *Scorer) FailWith(err error) *Scorer { + s.err = err + return s +} + +// Score returns the seeded tree for the batch. An unseeded batch returns an +// empty tree carrying the batch's identity. +func (s *Scorer) Score(_ context.Context, batch entity.Batch) (entity.SpeculationTree, error) { + if s.err != nil { + return entity.SpeculationTree{}, s.err + } + if tree, ok := s.trees[batch.ID]; ok { + return tree, nil + } + return entity.SpeculationTree{BatchID: batch.ID}, nil +} + +// ensure the fake satisfies the interface. +var _ scorer.Scorer = (*Scorer)(nil) diff --git a/submitqueue/extension/speculation/scorer/fake/fake_test.go b/submitqueue/extension/speculation/scorer/fake/fake_test.go new file mode 100644 index 00000000..796e16e6 --- /dev/null +++ b/submitqueue/extension/speculation/scorer/fake/fake_test.go @@ -0,0 +1,49 @@ +// 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 fake + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/submitqueue/entity" +) + +func TestScore_ReturnsSeededTree(t *testing.T) { + tree := entity.SpeculationTree{ + BatchID: "q/batch/2", + Paths: []entity.SpeculationPathInfo{ + {Path: entity.SpeculationPath{Head: "q/batch/2"}, Score: 0.9}, + }, + } + got, err := New().Set("q/batch/2", tree).Score(context.Background(), entity.Batch{ID: "q/batch/2"}) + require.NoError(t, err) + assert.Equal(t, tree, got) +} + +func TestScore_UnseededReturnsEmptyTreeWithID(t *testing.T) { + got, err := New().Score(context.Background(), entity.Batch{ID: "q/batch/9"}) + require.NoError(t, err) + assert.Equal(t, entity.SpeculationTree{BatchID: "q/batch/9"}, got) +} + +func TestScore_FailWith(t *testing.T) { + sentinel := errors.New("boom") + _, err := New().FailWith(sentinel).Score(context.Background(), entity.Batch{ID: "q/batch/1"}) + require.ErrorIs(t, err, sentinel) +} diff --git a/submitqueue/extension/speculation/scorer/mock/BUILD.bazel b/submitqueue/extension/speculation/scorer/mock/BUILD.bazel new file mode 100644 index 00000000..f9a46ac1 --- /dev/null +++ b/submitqueue/extension/speculation/scorer/mock/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["scorer_mock.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/scorer/mock", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/speculation/scorer:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + ], +) diff --git a/submitqueue/extension/speculation/scorer/mock/scorer_mock.go b/submitqueue/extension/speculation/scorer/mock/scorer_mock.go new file mode 100644 index 00000000..8b35ddb6 --- /dev/null +++ b/submitqueue/extension/speculation/scorer/mock/scorer_mock.go @@ -0,0 +1,97 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: scorer.go +// +// Generated by this command: +// +// mockgen -source=scorer.go -destination=mock/scorer_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + entity "github.com/uber/submitqueue/submitqueue/entity" + scorer "github.com/uber/submitqueue/submitqueue/extension/speculation/scorer" + gomock "go.uber.org/mock/gomock" +) + +// MockScorer is a mock of Scorer interface. +type MockScorer struct { + ctrl *gomock.Controller + recorder *MockScorerMockRecorder + isgomock struct{} +} + +// MockScorerMockRecorder is the mock recorder for MockScorer. +type MockScorerMockRecorder struct { + mock *MockScorer +} + +// NewMockScorer creates a new mock instance. +func NewMockScorer(ctrl *gomock.Controller) *MockScorer { + mock := &MockScorer{ctrl: ctrl} + mock.recorder = &MockScorerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockScorer) EXPECT() *MockScorerMockRecorder { + return m.recorder +} + +// Score mocks base method. +func (m *MockScorer) Score(ctx context.Context, batch entity.Batch) (entity.SpeculationTree, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Score", ctx, batch) + ret0, _ := ret[0].(entity.SpeculationTree) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Score indicates an expected call of Score. +func (mr *MockScorerMockRecorder) Score(ctx, batch any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Score", reflect.TypeOf((*MockScorer)(nil).Score), ctx, batch) +} + +// MockFactory is a mock of Factory interface. +type MockFactory struct { + ctrl *gomock.Controller + recorder *MockFactoryMockRecorder + isgomock struct{} +} + +// MockFactoryMockRecorder is the mock recorder for MockFactory. +type MockFactoryMockRecorder struct { + mock *MockFactory +} + +// NewMockFactory creates a new mock instance. +func NewMockFactory(ctrl *gomock.Controller) *MockFactory { + mock := &MockFactory{ctrl: ctrl} + mock.recorder = &MockFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { + return m.recorder +} + +// For mocks base method. +func (m *MockFactory) For(cfg scorer.Config) (scorer.Scorer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "For", cfg) + ret0, _ := ret[0].(scorer.Scorer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// For indicates an expected call of For. +func (mr *MockFactoryMockRecorder) For(cfg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockFactory)(nil).For), cfg) +} diff --git a/submitqueue/extension/speculation/scorer/scorer.go b/submitqueue/extension/speculation/scorer/scorer.go new file mode 100644 index 00000000..0f86b657 --- /dev/null +++ b/submitqueue/extension/speculation/scorer/scorer.go @@ -0,0 +1,75 @@ +// 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 scorer + +//go:generate mockgen -source=scorer.go -destination=mock/scorer_mock.go -package=mock + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/entity" +) + +// Scorer computes the predicted-success score of every path in a batch's +// speculation tree. +// +// A path's score is a prediction — "how likely is this bet to pay off?" — and +// predictions must move as evidence arrives. The scorer answers "how good is +// each path right now" from the current state: the per-batch success +// probabilities of a path's base batches (entity.Batch.Score, set by the score +// stage), which of those dependencies have already landed or had their build +// pass (resolved assumptions raise confidence), and optionally other signals +// (how long the batch has waited, historical pass rates). +// +// The controller re-runs the scorer on every respeculate, right after it +// reconciles path status — so when a dependency lands, its build passes, or a +// sibling path fails, the surviving paths' scores are recomputed against the new +// reality before anything is selected or prioritized. The controller drives +// *when* to rescore and persists the result; the scorer owns the *formula*. +// +// This is the per-*path* scorer, distinct from the per-*batch* score stage +// (extension/scorer), which sets entity.Batch.Score. The path scorer consumes +// those batch scores to score whole paths. +// +// The store is the source of truth. The scorer is handed only the batch identity +// and loads what it needs from storage — the batch's speculation tree (already +// carrying the controller-reconciled statuses) and the dependency batches +// (carrying their Batch.Score and current state) — through read access injected +// at its Factory. It never writes: it returns the scored tree and the controller +// persists the scores, keeping the controller the single writer of tree state. +type Scorer interface { + // Score loads the batch's speculation tree and dependency batches from + // storage and returns the tree with each path's Score set to its freshly + // computed predicted-success value. Path structure and controller-stamped + // Status are carried through unchanged; only Score is (re)computed. The + // combination formula is the implementation's concern. + Score(ctx context.Context, batch entity.Batch) (entity.SpeculationTree, error) +} + +// Config carries the per-queue identity handed to a Factory. The system knows +// only the queue name; everything an implementation needs (read access to the +// tree and batch stores, scoring knobs, extra signals) is injected at +// construction by the integrator. +type Config struct { + // QueueName identifies the queue this Scorer serves. + QueueName string +} + +// Factory builds the Scorer for a queue. Implementations are provided by +// integrators (and tests) and inject whatever they need at construction. +type Factory interface { + // For returns the Scorer for the given queue. + For(cfg Config) (Scorer, error) +} diff --git a/submitqueue/extension/speculation/selectionlimit/BUILD.bazel b/submitqueue/extension/speculation/selectionlimit/BUILD.bazel new file mode 100644 index 00000000..8dcc050e --- /dev/null +++ b/submitqueue/extension/speculation/selectionlimit/BUILD.bazel @@ -0,0 +1,8 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["selectionlimit.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selectionlimit", + visibility = ["//visibility:public"], +) diff --git a/submitqueue/extension/speculation/selectionlimit/README.md b/submitqueue/extension/speculation/selectionlimit/README.md new file mode 100644 index 00000000..15e56f98 --- /dev/null +++ b/submitqueue/extension/speculation/selectionlimit/README.md @@ -0,0 +1,17 @@ +# Speculation Selection Limit + +Vendor-agnostic "how much" policy that bounds how many paths a batch may build in parallel. + +See the [Speculation RFC](../../../../doc/rfc/submitqueue/speculation.md) for the end-to-end design and how limits fit into the two-layer speculation model. + +## Selection Limit + +The selection limit is the [selector](../selector)'s companion. The selector decides **which** of a batch's paths are worth building — its ranking over the tree; the selection limit decides **how many** of them may run at once. Keeping "which" and "how much" separate keeps selector logic free of resource accounting and lets the bound scale with build resources without touching that logic. + +The value is **signal-driven**, not a fixed constant. Its primary input is the build system's available capacity, but a policy may also weigh historical pass rates, cost budgets, time of day, or an experiment toggle. + +Unlike the dependency limit — which the controller holds and applies as an eligibility gate — the selection limit is **injected into the seam that uses it**: the selector is constructed with it and calls it itself, never receiving it as a method parameter. This follows the repo's extension-contract pattern (dependencies injected at the `Factory`), keeps the selector interface limit-free and stable, and lets the limit be swapped independently of selector logic. + +## Factory + +A per-queue factory returns the limit policy for a queue, following the repo's extension contract. It is handed only the queue identity; the signals a policy weighs — a capacity feed, historical metrics, config — are injected at construction by the integrator in the wiring layer, which is also where the limit is handed to the selector. Computing the limit itself takes no further inputs. diff --git a/submitqueue/extension/speculation/selectionlimit/fake/BUILD.bazel b/submitqueue/extension/speculation/selectionlimit/fake/BUILD.bazel new file mode 100644 index 00000000..cf5ff1ec --- /dev/null +++ b/submitqueue/extension/speculation/selectionlimit/fake/BUILD.bazel @@ -0,0 +1,19 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selectionlimit/fake", + visibility = ["//visibility:public"], + deps = ["//submitqueue/extension/speculation/selectionlimit:go_default_library"], +) + +go_test( + name = "go_default_test", + srcs = ["fake_test.go"], + embed = [":go_default_library"], + deps = [ + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/submitqueue/extension/speculation/selectionlimit/fake/fake.go b/submitqueue/extension/speculation/selectionlimit/fake/fake.go new file mode 100644 index 00000000..4311f831 --- /dev/null +++ b/submitqueue/extension/speculation/selectionlimit/fake/fake.go @@ -0,0 +1,52 @@ +// 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 fake provides a programmable selectionlimit.SelectionLimit for tests +// and examples. New sets the value returned by Limit; FailWith injects an error +// on every call. It is intended for examples and tests only, never production. +package fake + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/extension/speculation/selectionlimit" +) + +// SelectionLimit is a programmable selectionlimit.SelectionLimit. +type SelectionLimit struct { + limit int + err error +} + +// New returns a fake SelectionLimit whose Limit returns the given value. +func New(limit int) *SelectionLimit { + return &SelectionLimit{limit: limit} +} + +// FailWith makes every Limit call return err. +func (l *SelectionLimit) FailWith(err error) *SelectionLimit { + l.err = err + return l +} + +// Limit returns the configured value, or the injected error if FailWith was set. +func (l *SelectionLimit) Limit(_ context.Context) (int, error) { + if l.err != nil { + return 0, l.err + } + return l.limit, nil +} + +// ensure the fake satisfies the interface. +var _ selectionlimit.SelectionLimit = (*SelectionLimit)(nil) diff --git a/submitqueue/extension/speculation/selectionlimit/fake/fake_test.go b/submitqueue/extension/speculation/selectionlimit/fake/fake_test.go new file mode 100644 index 00000000..a69497b1 --- /dev/null +++ b/submitqueue/extension/speculation/selectionlimit/fake/fake_test.go @@ -0,0 +1,36 @@ +// 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 fake + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLimit_ReturnsConfiguredValue(t *testing.T) { + got, err := New(2).Limit(context.Background()) + require.NoError(t, err) + assert.Equal(t, 2, got) +} + +func TestLimit_FailWith(t *testing.T) { + sentinel := errors.New("boom") + _, err := New(2).FailWith(sentinel).Limit(context.Background()) + require.ErrorIs(t, err, sentinel) +} diff --git a/submitqueue/extension/speculation/selectionlimit/mock/BUILD.bazel b/submitqueue/extension/speculation/selectionlimit/mock/BUILD.bazel new file mode 100644 index 00000000..7d9fceec --- /dev/null +++ b/submitqueue/extension/speculation/selectionlimit/mock/BUILD.bazel @@ -0,0 +1,12 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["selectionlimit_mock.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selectionlimit/mock", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/extension/speculation/selectionlimit:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + ], +) diff --git a/submitqueue/extension/speculation/selectionlimit/mock/selectionlimit_mock.go b/submitqueue/extension/speculation/selectionlimit/mock/selectionlimit_mock.go new file mode 100644 index 00000000..f4cbbf14 --- /dev/null +++ b/submitqueue/extension/speculation/selectionlimit/mock/selectionlimit_mock.go @@ -0,0 +1,96 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: selectionlimit.go +// +// Generated by this command: +// +// mockgen -source=selectionlimit.go -destination=mock/selectionlimit_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + selectionlimit "github.com/uber/submitqueue/submitqueue/extension/speculation/selectionlimit" + gomock "go.uber.org/mock/gomock" +) + +// MockSelectionLimit is a mock of SelectionLimit interface. +type MockSelectionLimit struct { + ctrl *gomock.Controller + recorder *MockSelectionLimitMockRecorder + isgomock struct{} +} + +// MockSelectionLimitMockRecorder is the mock recorder for MockSelectionLimit. +type MockSelectionLimitMockRecorder struct { + mock *MockSelectionLimit +} + +// NewMockSelectionLimit creates a new mock instance. +func NewMockSelectionLimit(ctrl *gomock.Controller) *MockSelectionLimit { + mock := &MockSelectionLimit{ctrl: ctrl} + mock.recorder = &MockSelectionLimitMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSelectionLimit) EXPECT() *MockSelectionLimitMockRecorder { + return m.recorder +} + +// Limit mocks base method. +func (m *MockSelectionLimit) Limit(ctx context.Context) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Limit", ctx) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Limit indicates an expected call of Limit. +func (mr *MockSelectionLimitMockRecorder) Limit(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Limit", reflect.TypeOf((*MockSelectionLimit)(nil).Limit), ctx) +} + +// MockFactory is a mock of Factory interface. +type MockFactory struct { + ctrl *gomock.Controller + recorder *MockFactoryMockRecorder + isgomock struct{} +} + +// MockFactoryMockRecorder is the mock recorder for MockFactory. +type MockFactoryMockRecorder struct { + mock *MockFactory +} + +// NewMockFactory creates a new mock instance. +func NewMockFactory(ctrl *gomock.Controller) *MockFactory { + mock := &MockFactory{ctrl: ctrl} + mock.recorder = &MockFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { + return m.recorder +} + +// For mocks base method. +func (m *MockFactory) For(cfg selectionlimit.Config) (selectionlimit.SelectionLimit, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "For", cfg) + ret0, _ := ret[0].(selectionlimit.SelectionLimit) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// For indicates an expected call of For. +func (mr *MockFactoryMockRecorder) For(cfg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockFactory)(nil).For), cfg) +} diff --git a/submitqueue/extension/speculation/selectionlimit/selectionlimit.go b/submitqueue/extension/speculation/selectionlimit/selectionlimit.go new file mode 100644 index 00000000..83a0fb59 --- /dev/null +++ b/submitqueue/extension/speculation/selectionlimit/selectionlimit.go @@ -0,0 +1,57 @@ +// 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 selectionlimit + +//go:generate mockgen -source=selectionlimit.go -destination=mock/selectionlimit_mock.go -package=mock + +import "context" + +// SelectionLimit is the "how much" policy that bounds how many paths a batch may +// build in parallel. +// +// It is the selector's companion: the selector decides *which* of a batch's +// paths are worth building (its ranking); the selection limit decides *how many* +// of them may run at once. Separating the two keeps selector logic free of +// resource accounting and lets the bound scale with build resources without +// touching that logic. +// +// The value is dynamic: it may change between calls, so the selector reads it +// each pass rather than caching it. +// +// Unlike the dependency limit, this limit is injected into the seam that uses it +// — the selector is constructed with it and calls it itself — never passed as a +// method parameter, keeping the selector interface limit-free and stable. +type SelectionLimit interface { + // Limit returns the current maximum number of paths a batch may build in + // parallel. The selector caps its Build actions at this. It takes no + // parameters; anything an implementation needs is injected at construction. + Limit(ctx context.Context) (int, error) +} + +// Config carries the per-queue identity handed to a Factory. The system knows +// only the queue name; everything a policy needs to compute the limit (a +// capacity feed, historical metrics, config) is injected at construction by the +// integrator. +type Config struct { + // QueueName identifies the queue this SelectionLimit serves. + QueueName string +} + +// Factory builds the SelectionLimit for a queue. Implementations are provided by +// integrators (and tests) and inject whatever signals they need at construction. +type Factory interface { + // For returns the SelectionLimit for the given queue. + For(cfg Config) (SelectionLimit, error) +} diff --git a/submitqueue/extension/speculation/selector/BUILD.bazel b/submitqueue/extension/speculation/selector/BUILD.bazel new file mode 100644 index 00000000..aca92b3d --- /dev/null +++ b/submitqueue/extension/speculation/selector/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["selector.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selector", + visibility = ["//visibility:public"], + deps = ["//submitqueue/entity:go_default_library"], +) diff --git a/submitqueue/extension/speculation/selector/README.md b/submitqueue/extension/speculation/selector/README.md new file mode 100644 index 00000000..258ad8c8 --- /dev/null +++ b/submitqueue/extension/speculation/selector/README.md @@ -0,0 +1,19 @@ +# Speculation Path Selector + +Vendor-agnostic interface for deciding what the orchestrator should do with each path in a batch's enumerated speculation tree. + +See the [Speculation RFC](../../../../doc/rfc/submitqueue/speculation.md) for the end-to-end design and how selection fits into the orchestrator pipeline. + +## Selector + +A selector is the **policy** — the part that decides how aggressively to spend build resources. *Given the candidate paths in the batch's tree and their current status, what should we do with each, right now?* It returns an **action** per path — `Build` or `Cancel`. Strategies span a spectrum: build only the single optimistic path (cheapest — bet on the happy case), build every candidate (maximum parallelism, maximum build cost), or a top-K / budget-bounded subset in between. + +The selector decides only where to spend build resources. It does **not** decide merging: a path becomes mergeable when its build passed and its base matches what actually landed, which is deterministic, not a policy choice — so the controller finalizes it on its own. + +The **store is the source of truth**. The selector is handed only the batch identity and loads that batch's tree from storage through read access injected at its factory. The controller is the single writer — it reconciles each path's status (candidate, building, passed, failed, cancelled) from the latest builds and dependency states and persists it, plus the score — so the stored tree the selector reads is always the up-to-date input. The selector's only output is actions; it **never** writes status. This keeps it a deterministic policy over stored state. + +Because it is re-run on every build signal, a selector can start narrow — build the optimistic path first — and widen later, committing more paths only once earlier bets resolve. Returning no action for a path leaves it as-is. Policy parameters — a top-K cap, a build budget, an experiment toggle — are configured when the selector is constructed rather than passed through this contract. + +## Factory + +A per-queue factory returns the selector for a queue, following the repo's extension contract. It is handed only the queue identity and nothing else; policy knobs — a top-K cap, a build budget, an experiment toggle — are injected at construction by the integrator in the wiring layer, which resolves per-queue settings through `queueconfig`. Selection itself stays config-free. diff --git a/submitqueue/extension/speculation/selector/fake/BUILD.bazel b/submitqueue/extension/speculation/selector/fake/BUILD.bazel new file mode 100644 index 00000000..7013e840 --- /dev/null +++ b/submitqueue/extension/speculation/selector/fake/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["fake.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selector/fake", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/speculation/selector:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["fake_test.go"], + embed = [":go_default_library"], + deps = [ + "//submitqueue/entity:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/submitqueue/extension/speculation/selector/fake/fake.go b/submitqueue/extension/speculation/selector/fake/fake.go new file mode 100644 index 00000000..862df3c3 --- /dev/null +++ b/submitqueue/extension/speculation/selector/fake/fake.go @@ -0,0 +1,61 @@ +// 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 fake provides a programmable selector.Selector for tests and examples. +// It returns the decisions seeded via SetDecisions (none by default, i.e. leave +// every path as-is). FailWith injects an error on every call. It is intended for +// examples and tests only, never production. +package fake + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/speculation/selector" +) + +// Selector is a programmable selector.Selector. +type Selector struct { + decisions []entity.SpeculationPathDecision + err error +} + +// New returns a fake Selector that decides nothing (leaves every path as-is). +// Seed decisions with SetDecisions. +func New() *Selector { + return &Selector{} +} + +// SetDecisions seeds the decisions returned by Select. +func (s *Selector) SetDecisions(decisions ...entity.SpeculationPathDecision) *Selector { + s.decisions = decisions + return s +} + +// FailWith makes every Select call return err. +func (s *Selector) FailWith(err error) *Selector { + s.err = err + return s +} + +// Select returns the seeded decisions. The batch argument is ignored. +func (s *Selector) Select(_ context.Context, _ entity.Batch) ([]entity.SpeculationPathDecision, error) { + if s.err != nil { + return nil, s.err + } + return s.decisions, nil +} + +// ensure the fake satisfies the interface. +var _ selector.Selector = (*Selector)(nil) diff --git a/submitqueue/extension/speculation/selector/fake/fake_test.go b/submitqueue/extension/speculation/selector/fake/fake_test.go new file mode 100644 index 00000000..5353933d --- /dev/null +++ b/submitqueue/extension/speculation/selector/fake/fake_test.go @@ -0,0 +1,46 @@ +// 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 fake + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/submitqueue/entity" +) + +func TestSelect_DefaultDecidesNothing(t *testing.T) { + got, err := New().Select(context.Background(), entity.Batch{ID: "q/batch/2"}) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestSelect_ReturnsSeededDecisions(t *testing.T) { + want := []entity.SpeculationPathDecision{ + {Path: entity.SpeculationPath{Head: "q/batch/2"}, Action: entity.SpeculationPathActionBuild}, + } + got, err := New().SetDecisions(want...).Select(context.Background(), entity.Batch{ID: "q/batch/2"}) + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestSelect_FailWith(t *testing.T) { + sentinel := errors.New("boom") + _, err := New().FailWith(sentinel).Select(context.Background(), entity.Batch{ID: "q/batch/2"}) + require.ErrorIs(t, err, sentinel) +} diff --git a/submitqueue/extension/speculation/selector/mock/BUILD.bazel b/submitqueue/extension/speculation/selector/mock/BUILD.bazel new file mode 100644 index 00000000..e31a192b --- /dev/null +++ b/submitqueue/extension/speculation/selector/mock/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["selector_mock.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selector/mock", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/speculation/selector:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + ], +) diff --git a/submitqueue/extension/speculation/selector/mock/selector_mock.go b/submitqueue/extension/speculation/selector/mock/selector_mock.go new file mode 100644 index 00000000..b0aa4ed1 --- /dev/null +++ b/submitqueue/extension/speculation/selector/mock/selector_mock.go @@ -0,0 +1,97 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: selector.go +// +// Generated by this command: +// +// mockgen -source=selector.go -destination=mock/selector_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + entity "github.com/uber/submitqueue/submitqueue/entity" + selector "github.com/uber/submitqueue/submitqueue/extension/speculation/selector" + gomock "go.uber.org/mock/gomock" +) + +// MockSelector is a mock of Selector interface. +type MockSelector struct { + ctrl *gomock.Controller + recorder *MockSelectorMockRecorder + isgomock struct{} +} + +// MockSelectorMockRecorder is the mock recorder for MockSelector. +type MockSelectorMockRecorder struct { + mock *MockSelector +} + +// NewMockSelector creates a new mock instance. +func NewMockSelector(ctrl *gomock.Controller) *MockSelector { + mock := &MockSelector{ctrl: ctrl} + mock.recorder = &MockSelectorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSelector) EXPECT() *MockSelectorMockRecorder { + return m.recorder +} + +// Select mocks base method. +func (m *MockSelector) Select(ctx context.Context, batch entity.Batch) ([]entity.SpeculationPathDecision, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Select", ctx, batch) + ret0, _ := ret[0].([]entity.SpeculationPathDecision) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Select indicates an expected call of Select. +func (mr *MockSelectorMockRecorder) Select(ctx, batch any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Select", reflect.TypeOf((*MockSelector)(nil).Select), ctx, batch) +} + +// MockFactory is a mock of Factory interface. +type MockFactory struct { + ctrl *gomock.Controller + recorder *MockFactoryMockRecorder + isgomock struct{} +} + +// MockFactoryMockRecorder is the mock recorder for MockFactory. +type MockFactoryMockRecorder struct { + mock *MockFactory +} + +// NewMockFactory creates a new mock instance. +func NewMockFactory(ctrl *gomock.Controller) *MockFactory { + mock := &MockFactory{ctrl: ctrl} + mock.recorder = &MockFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { + return m.recorder +} + +// For mocks base method. +func (m *MockFactory) For(cfg selector.Config) (selector.Selector, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "For", cfg) + ret0, _ := ret[0].(selector.Selector) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// For indicates an expected call of For. +func (mr *MockFactoryMockRecorder) For(cfg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockFactory)(nil).For), cfg) +} diff --git a/submitqueue/extension/speculation/selector/selector.go b/submitqueue/extension/speculation/selector/selector.go new file mode 100644 index 00000000..29f8de9d --- /dev/null +++ b/submitqueue/extension/speculation/selector/selector.go @@ -0,0 +1,63 @@ +// 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 selector + +//go:generate mockgen -source=selector.go -destination=mock/selector_mock.go -package=mock + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/entity" +) + +// Selector decides what the controller should do with each path in a batch's +// speculation tree. +// +// Selection is the policy: it answers "which futures do we spend build resources +// on, and how many, right now". It reads the batch's tree — including each path's +// controller-stamped Status (Candidate / Building / Passed / Failed / Cancelled) +// and Score — and returns an action per path it wants to act on. +// +// The store is the source of truth. The selector is handed only the batch +// identity and loads the tree from storage through read access injected at its +// Factory; the controller, which persists every Status and Score write, is the +// single writer, so the stored tree the selector reads is always the up-to-date +// input. The selector's only output is actions; it never writes Status. This +// keeps it a deterministic policy over stored state. Policy knobs such as a top-K +// limit or budget belong to the implementation's construction, not this method. +type Selector interface { + // Select loads the batch's speculation tree from storage and returns the + // actions to take for it. Returning multiple Build decisions dispatches + // several speculative builds in parallel; an empty result means nothing should + // be done right now. Paths the selector has no opinion on are simply omitted + // (leave-as-is). + Select(ctx context.Context, batch entity.Batch) ([]entity.SpeculationPathDecision, error) +} + +// Config carries the per-queue identity handed to a Factory. The system knows +// only the queue name; everything an implementation needs (read access to the +// tree store, policy knobs such as a top-K cap or build budget) is injected at +// construction by the integrator. +type Config struct { + // QueueName identifies the queue this Selector serves. + QueueName string +} + +// Factory builds the Selector for a queue. Implementations are provided by +// integrators (and tests) and inject whatever they need at construction. +type Factory interface { + // For returns the Selector for the given queue. + For(cfg Config) (Selector, error) +} diff --git a/submitqueue/extension/storage/mock/speculation_tree_store_mock.go b/submitqueue/extension/storage/mock/speculation_tree_store_mock.go index e7fba22b..c5055fd6 100644 --- a/submitqueue/extension/storage/mock/speculation_tree_store_mock.go +++ b/submitqueue/extension/storage/mock/speculation_tree_store_mock.go @@ -70,16 +70,16 @@ func (mr *MockSpeculationTreeStoreMockRecorder) Get(ctx, batchID any) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSpeculationTreeStore)(nil).Get), ctx, batchID) } -// UpdateSpeculations mocks base method. -func (m *MockSpeculationTreeStore) UpdateSpeculations(ctx context.Context, batchID string, speculations []entity.SpeculationInfo) error { +// Update mocks base method. +func (m *MockSpeculationTreeStore) Update(ctx context.Context, speculationTree entity.SpeculationTree) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateSpeculations", ctx, batchID, speculations) + ret := m.ctrl.Call(m, "Update", ctx, speculationTree) ret0, _ := ret[0].(error) return ret0 } -// UpdateSpeculations indicates an expected call of UpdateSpeculations. -func (mr *MockSpeculationTreeStoreMockRecorder) UpdateSpeculations(ctx, batchID, speculations any) *gomock.Call { +// Update indicates an expected call of Update. +func (mr *MockSpeculationTreeStoreMockRecorder) Update(ctx, speculationTree any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSpeculations", reflect.TypeOf((*MockSpeculationTreeStore)(nil).UpdateSpeculations), ctx, batchID, speculations) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSpeculationTreeStore)(nil).Update), ctx, speculationTree) } diff --git a/submitqueue/extension/storage/mysql/build_store.go b/submitqueue/extension/storage/mysql/build_store.go index d04eae53..c5ea9fc3 100644 --- a/submitqueue/extension/storage/mysql/build_store.go +++ b/submitqueue/extension/storage/mysql/build_store.go @@ -48,9 +48,9 @@ func (s *buildStore) Get(ctx context.Context, id string) (ret entity.Build, retE var speculationPathJSON []byte err := s.db.QueryRowContext(ctx, - "SELECT id, batch_id, speculation_path, score, status FROM build WHERE id = ?", + "SELECT id, batch_id, speculation_path, status FROM build WHERE id = ?", id, - ).Scan(&build.ID, &build.BatchID, &speculationPathJSON, &build.Score, &build.Status) + ).Scan(&build.ID, &build.BatchID, &speculationPathJSON, &build.Status) if errors.Is(err, sql.ErrNoRows) { return entity.Build{}, storage.WrapNotFound(err) @@ -77,8 +77,8 @@ func (s *buildStore) Create(ctx context.Context, build entity.Build) (retErr err } _, err = s.db.ExecContext(ctx, - "INSERT INTO build (id, batch_id, speculation_path, score, status) VALUES (?, ?, ?, ?, ?)", - build.ID, build.BatchID, speculationPathJSON, build.Score, build.Status, + "INSERT INTO build (id, batch_id, speculation_path, status) VALUES (?, ?, ?, ?)", + build.ID, build.BatchID, speculationPathJSON, build.Status, ) if err != nil { var mysqlErr *mysql.MySQLError diff --git a/submitqueue/extension/storage/mysql/schema/build.sql b/submitqueue/extension/storage/mysql/schema/build.sql index 93bc5744..8aea8230 100644 --- a/submitqueue/extension/storage/mysql/schema/build.sql +++ b/submitqueue/extension/storage/mysql/schema/build.sql @@ -2,7 +2,6 @@ CREATE TABLE IF NOT EXISTS build ( id VARCHAR(255) NOT NULL, batch_id VARCHAR(255) NOT NULL, speculation_path JSON NOT NULL, - score FLOAT NOT NULL, status VARCHAR(64) NOT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/submitqueue/extension/storage/mysql/schema/speculation_tree.sql b/submitqueue/extension/storage/mysql/schema/speculation_tree.sql index 5b1d3a19..ecbb976a 100644 --- a/submitqueue/extension/storage/mysql/schema/speculation_tree.sql +++ b/submitqueue/extension/storage/mysql/schema/speculation_tree.sql @@ -1,5 +1,5 @@ CREATE TABLE IF NOT EXISTS speculation_tree ( batch_id VARCHAR(255) NOT NULL, - speculations JSON NOT NULL, + paths JSON NOT NULL, PRIMARY KEY (batch_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/submitqueue/extension/storage/mysql/speculation_tree_store.go b/submitqueue/extension/storage/mysql/speculation_tree_store.go index 564cd778..32bda7c5 100644 --- a/submitqueue/extension/storage/mysql/speculation_tree_store.go +++ b/submitqueue/extension/storage/mysql/speculation_tree_store.go @@ -45,12 +45,12 @@ func (s *speculationTreeStore) Get(ctx context.Context, batchID string) (ret ent defer func() { op.Complete(retErr) }() var st entity.SpeculationTree - var speculationsJSON []byte + var pathsJSON []byte err := s.db.QueryRowContext(ctx, - "SELECT batch_id, speculations FROM speculation_tree WHERE batch_id = ?", + "SELECT batch_id, paths FROM speculation_tree WHERE batch_id = ?", batchID, - ).Scan(&st.BatchID, &speculationsJSON) + ).Scan(&st.BatchID, &pathsJSON) if errors.Is(err, sql.ErrNoRows) { return entity.SpeculationTree{}, storage.WrapNotFound(err) @@ -59,8 +59,8 @@ func (s *speculationTreeStore) Get(ctx context.Context, batchID string) (ret ent return entity.SpeculationTree{}, fmt.Errorf("failed to get speculation tree entity batchID=%s from the database: %w", batchID, err) } - if err := json.Unmarshal(speculationsJSON, &st.Speculations); err != nil { - return entity.SpeculationTree{}, fmt.Errorf("failed to unmarshal speculations for speculation tree entity batchID=%s from the database: %w", batchID, err) + if err := json.Unmarshal(pathsJSON, &st.Paths); err != nil { + return entity.SpeculationTree{}, fmt.Errorf("failed to unmarshal paths for speculation tree entity batchID=%s from the database: %w", batchID, err) } return st, nil @@ -71,14 +71,14 @@ func (s *speculationTreeStore) Create(ctx context.Context, speculationTree entit op := metrics.Begin(s.scope, "create") defer func() { op.Complete(retErr) }() - speculationsJSON, err := json.Marshal(speculationTree.Speculations) + pathsJSON, err := json.Marshal(speculationTree.Paths) if err != nil { - return fmt.Errorf("failed to marshal speculations batchID=%s for Create speculation tree entity: %w", speculationTree.BatchID, err) + return fmt.Errorf("failed to marshal paths batchID=%s for Create speculation tree entity: %w", speculationTree.BatchID, err) } _, err = s.db.ExecContext(ctx, - "INSERT INTO speculation_tree (batch_id, speculations) VALUES (?, ?)", - speculationTree.BatchID, speculationsJSON, + "INSERT INTO speculation_tree (batch_id, paths) VALUES (?, ?)", + speculationTree.BatchID, pathsJSON, ) if err != nil { var mysqlErr *mysql.MySQLError @@ -91,31 +91,32 @@ func (s *speculationTreeStore) Create(ctx context.Context, speculationTree entit return nil } -// UpdateSpeculations updates the speculations of a speculation tree. Returns ErrNotFound if the speculation tree is not found. -func (s *speculationTreeStore) UpdateSpeculations(ctx context.Context, batchID string, speculations []entity.SpeculationInfo) (retErr error) { - op := metrics.Begin(s.scope, "update_speculations") +// Update overwrites the paths of an existing speculation tree, identified by +// speculationTree.BatchID. Returns ErrNotFound if the speculation tree is not found. +func (s *speculationTreeStore) Update(ctx context.Context, speculationTree entity.SpeculationTree) (retErr error) { + op := metrics.Begin(s.scope, "update") defer func() { op.Complete(retErr) }() - speculationsJSON, err := json.Marshal(speculations) + pathsJSON, err := json.Marshal(speculationTree.Paths) if err != nil { - return fmt.Errorf("failed to marshal speculations batchID=%s for UpdateSpeculations: %w", batchID, err) + return fmt.Errorf("failed to marshal paths batchID=%s for Update: %w", speculationTree.BatchID, err) } result, err := s.db.ExecContext(ctx, - "UPDATE speculation_tree SET speculations = ? WHERE batch_id = ?", - speculationsJSON, batchID, + "UPDATE speculation_tree SET paths = ? WHERE batch_id = ?", + pathsJSON, speculationTree.BatchID, ) if err != nil { - return fmt.Errorf("failed to update speculations for batchID=%q: %w", batchID, err) + return fmt.Errorf("failed to update speculation tree for batchID=%q: %w", speculationTree.BatchID, err) } rowsAffected, err := result.RowsAffected() if err != nil { - return fmt.Errorf("failed to get rows affected from update for batchID=%q: %w", batchID, err) + return fmt.Errorf("failed to get rows affected from update for batchID=%q: %w", speculationTree.BatchID, err) } if rowsAffected != 1 { - return storage.WrapNotFound(fmt.Errorf("speculation tree entity batchID=%s", batchID)) + return storage.WrapNotFound(fmt.Errorf("speculation tree entity batchID=%s", speculationTree.BatchID)) } return nil diff --git a/submitqueue/extension/storage/speculation_tree_store.go b/submitqueue/extension/storage/speculation_tree_store.go index 0b021bba..b282d60b 100644 --- a/submitqueue/extension/storage/speculation_tree_store.go +++ b/submitqueue/extension/storage/speculation_tree_store.go @@ -32,7 +32,7 @@ type SpeculationTreeStore interface { // Returns ErrAlreadyExists if the entry already exists. Create(ctx context.Context, speculationTree entity.SpeculationTree) error - // UpdateSpeculations updates the speculations of a speculation tree. - // Returns ErrNotFound if the speculation tree is not found. - UpdateSpeculations(ctx context.Context, batchID string, speculations []entity.SpeculationInfo) error + // Update overwrites the paths of an existing speculation tree, identified by + // speculationTree.BatchID. Returns ErrNotFound if the speculation tree is not found. + Update(ctx context.Context, speculationTree entity.SpeculationTree) error } diff --git a/submitqueue/orchestrator/controller/build/build.go b/submitqueue/orchestrator/controller/build/build.go index 6edbe3f5..85f1f68e 100644 --- a/submitqueue/orchestrator/controller/build/build.go +++ b/submitqueue/orchestrator/controller/build/build.go @@ -140,7 +140,7 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (r build := entity.Build{ ID: buildID.ID, BatchID: batch.ID, - SpeculationPath: entity.SpeculationPathInfo{Base: append([]string{}, batch.Dependencies...)}, + SpeculationPath: entity.SpeculationPath{Base: append([]string{}, batch.Dependencies...), Head: batch.ID}, Status: entity.BuildStatusAccepted, } diff --git a/test/integration/submitqueue/extension/storage/suite.go b/test/integration/submitqueue/extension/storage/suite.go index 23984461..46ba243f 100644 --- a/test/integration/submitqueue/extension/storage/suite.go +++ b/test/integration/submitqueue/extension/storage/suite.go @@ -381,3 +381,101 @@ func (s *StorageContractSuite) TestStorage_ChangeCreate_EmptyDetails() { require.Len(t, got, 1) assert.Equal(t, entity.ChangeDetails{}, got[0].Details) } + +// sampleSpeculationTree returns a representative tree for the given batch: a +// fallback path (build alone) and a speculative path (build on an assumed-good +// base), exercising every SpeculationPathInfo field. +func sampleSpeculationTree(batchID string) entity.SpeculationTree { + return entity.SpeculationTree{ + BatchID: batchID, + Paths: []entity.SpeculationPathInfo{ + { + Path: entity.SpeculationPath{Base: nil, Head: batchID}, + Score: 0.5, + Status: entity.SpeculationPathStatusCandidate, + }, + { + Path: entity.SpeculationPath{Base: []string{"q/batch/1", "q/batch/2"}, Head: batchID}, + Score: 0.25, + Status: entity.SpeculationPathStatusBuilding, + BuildID: "build-42", + }, + }, + } +} + +// TestStorage_SpeculationCreateAndGet verifies a tree round-trips through the +// store preserving every path field (Base/Head, Score, Status, BuildID). +func (s *StorageContractSuite) TestStorage_SpeculationCreateAndGet() { + t := s.T() + ctx := s.ctx + + tree := sampleSpeculationTree("spec/create-get") + + require.NoError(t, s.storage.GetSpeculationTreeStore().Create(ctx, tree)) + + retrieved, err := s.storage.GetSpeculationTreeStore().Get(ctx, tree.BatchID) + require.NoError(t, err) + assert.Equal(t, tree, retrieved, "speculation tree should round-trip unchanged") +} + +// TestStorage_SpeculationCreateDuplicate verifies a repeated Create for the same +// batch returns ErrAlreadyExists. +func (s *StorageContractSuite) TestStorage_SpeculationCreateDuplicate() { + t := s.T() + ctx := s.ctx + + tree := sampleSpeculationTree("spec/duplicate") + + require.NoError(t, s.storage.GetSpeculationTreeStore().Create(ctx, tree)) + + err := s.storage.GetSpeculationTreeStore().Create(ctx, tree) + assert.ErrorIs(t, err, storage.ErrAlreadyExists, "duplicate create should return ErrAlreadyExists") +} + +// TestStorage_SpeculationUpdate verifies Update overwrites the entire set of +// paths for a batch (the controller persists the whole tree each respeculate). +func (s *StorageContractSuite) TestStorage_SpeculationUpdate() { + t := s.T() + ctx := s.ctx + + tree := sampleSpeculationTree("spec/update") + require.NoError(t, s.storage.GetSpeculationTreeStore().Create(ctx, tree)) + + // Respeculate: the speculative base broke, so its path is cancelled and the + // fallback advanced to passed — a wholesale replacement of the paths. + updated := entity.SpeculationTree{ + BatchID: tree.BatchID, + Paths: []entity.SpeculationPathInfo{ + { + Path: entity.SpeculationPath{Base: nil, Head: tree.BatchID}, + Score: 0.75, + Status: entity.SpeculationPathStatusPassed, + BuildID: "build-99", + }, + }, + } + require.NoError(t, s.storage.GetSpeculationTreeStore().Update(ctx, updated)) + + retrieved, err := s.storage.GetSpeculationTreeStore().Get(ctx, tree.BatchID) + require.NoError(t, err) + assert.Equal(t, updated, retrieved, "Update should overwrite the whole tree") +} + +// TestStorage_SpeculationGetNotFound verifies Get for an unknown batch returns ErrNotFound. +func (s *StorageContractSuite) TestStorage_SpeculationGetNotFound() { + t := s.T() + ctx := s.ctx + + _, err := s.storage.GetSpeculationTreeStore().Get(ctx, "spec/nonexistent") + assert.ErrorIs(t, err, storage.ErrNotFound, "Get for unknown batch should return ErrNotFound") +} + +// TestStorage_SpeculationUpdateNotFound verifies Update for an unknown batch returns ErrNotFound. +func (s *StorageContractSuite) TestStorage_SpeculationUpdateNotFound() { + t := s.T() + ctx := s.ctx + + err := s.storage.GetSpeculationTreeStore().Update(ctx, sampleSpeculationTree("spec/update-nonexistent")) + assert.ErrorIs(t, err, storage.ErrNotFound, "Update for unknown batch should return ErrNotFound") +}