diff --git a/submitqueue/extension/speculation/dependencylimit/static/BUILD.bazel b/submitqueue/extension/speculation/dependencylimit/static/BUILD.bazel new file mode 100644 index 00000000..2fa9a906 --- /dev/null +++ b/submitqueue/extension/speculation/dependencylimit/static/BUILD.bazel @@ -0,0 +1,19 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["static.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit/static", + visibility = ["//visibility:public"], + deps = ["//submitqueue/extension/speculation/dependencylimit:go_default_library"], +) + +go_test( + name = "go_default_test", + srcs = ["static_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/static/README.md b/submitqueue/extension/speculation/dependencylimit/static/README.md new file mode 100644 index 00000000..03595e91 --- /dev/null +++ b/submitqueue/extension/speculation/dependencylimit/static/README.md @@ -0,0 +1,3 @@ +# Static Dependency Limit + +The static `dependencylimit.DependencyLimit` is constructed with a single integer and returns it, unchanged, from every call to `Limit`. It never errors and never consults any external signal — the simplest possible dependency limit, useful for wiring tests, local development, and any queue whose eligibility bound is a fixed operational constant rather than one derived from live capacity or other signals. diff --git a/submitqueue/extension/speculation/dependencylimit/static/static.go b/submitqueue/extension/speculation/dependencylimit/static/static.go new file mode 100644 index 00000000..1778b1c0 --- /dev/null +++ b/submitqueue/extension/speculation/dependencylimit/static/static.go @@ -0,0 +1,42 @@ +// 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 static provides a dependencylimit.DependencyLimit that always +// returns a fixed, construction-time value. +package static + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit" +) + +// staticLimit is a dependencylimit.DependencyLimit that always returns a +// fixed value. +type staticLimit struct { + // limit is the fixed maximum active-dependency count returned by every + // call to Limit. + limit int +} + +// New returns a dependencylimit.DependencyLimit whose Limit always returns +// limit. +func New(limit int) dependencylimit.DependencyLimit { + return staticLimit{limit: limit} +} + +// Limit returns the fixed value given to New. It never errors. +func (l staticLimit) Limit(_ context.Context) (int, error) { + return l.limit, nil +} diff --git a/submitqueue/extension/speculation/dependencylimit/static/static_test.go b/submitqueue/extension/speculation/dependencylimit/static/static_test.go new file mode 100644 index 00000000..e3f3fa9e --- /dev/null +++ b/submitqueue/extension/speculation/dependencylimit/static/static_test.go @@ -0,0 +1,35 @@ +// 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 static + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLimit_ReturnsConfiguredValue(t *testing.T) { + got, err := New(4).Limit(context.Background()) + require.NoError(t, err) + assert.Equal(t, 4, got) +} + +func TestLimit_ZeroValue(t *testing.T) { + got, err := New(0).Limit(context.Background()) + require.NoError(t, err) + assert.Equal(t, 0, got) +} diff --git a/submitqueue/extension/speculation/enumerator/chain/BUILD.bazel b/submitqueue/extension/speculation/enumerator/chain/BUILD.bazel new file mode 100644 index 00000000..3d276c10 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/chain/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["chain.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator/chain", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/speculation/enumerator:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["chain_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/chain/README.md b/submitqueue/extension/speculation/enumerator/chain/README.md new file mode 100644 index 00000000..9a2d2ec3 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/chain/README.md @@ -0,0 +1,7 @@ +# Chain Enumerator + +Given a batch and its active dependency batches in arrival order, the chain `enumerator.Enumerator` produces exactly one candidate path: the batch built directly on top of the full chain of those dependencies, in the order given. It never branches, so a batch's tree only ever contains this single path. An empty dependency list still yields one path, whose base is empty — the batch built directly on the target branch. + +This is deliberately the least interesting enumerator: it does not weigh which dependencies to include or exclude, does not consider dropping a shaky predecessor, and does not produce a "build alone" alternative alongside the chained one. It is the working, deterministic single-chain baseline — a richer enumerator (for example, one that also offers a build-alone fallback path per dependency) can replace or supplement it without changing the contract. + +As with any `enumerator.Enumerator`, the returned path carries structure only — a Base/Head split. The controller assigns its identity, stamps its status, and has the scorer fill its score. diff --git a/submitqueue/extension/speculation/enumerator/chain/chain.go b/submitqueue/extension/speculation/enumerator/chain/chain.go new file mode 100644 index 00000000..f4a18171 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/chain/chain.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 chain provides an enumerator.Enumerator that enumerates a batch +// as exactly one path, built directly on top of the full ordered chain of +// its active dependencies. It is the single-chain baseline; a multi-path +// enumerator (e.g. one adding build-alone fallback paths) can replace or +// supplement it without changing the contract. +package chain + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator" +) + +// chainEnumerator is an enumerator.Enumerator that always enumerates a single +// path per batch. +type chainEnumerator struct{} + +// New returns an enumerator.Enumerator that enumerates exactly one path per +// batch: the path's Base is deps in the given order and its Head is the +// batch itself. +func New() enumerator.Enumerator { + return chainEnumerator{} +} + +// Enumerate returns exactly one path whose Base preserves deps' order and +// whose Head is the batch — including when deps is empty, in which case the +// single path has an empty Base (the batch builds directly on the target +// branch). The controller owns everything beyond structure: it assigns the +// path its identity, stamps its status, and has the scorer fill its score. +func (chainEnumerator) Enumerate(_ context.Context, batch entity.Batch, deps []entity.Batch) ([]entity.SpeculationPath, error) { + var base []string + for _, dep := range deps { + base = append(base, dep.ID) + } + return []entity.SpeculationPath{{Base: base, Head: batch.ID}}, nil +} diff --git a/submitqueue/extension/speculation/enumerator/chain/chain_test.go b/submitqueue/extension/speculation/enumerator/chain/chain_test.go new file mode 100644 index 00000000..9b96b11b --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/chain/chain_test.go @@ -0,0 +1,64 @@ +// 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 chain + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/submitqueue/entity" +) + +func TestEnumerate(t *testing.T) { + tests := []struct { + name string + batchID string + deps []entity.Batch + want []entity.SpeculationPath + }{ + { + name: "no deps yields one path with an empty base", + batchID: "q/batch/1", + deps: nil, + want: []entity.SpeculationPath{{Head: "q/batch/1"}}, + }, + { + name: "deps preserve input order in base", + batchID: "q/batch/4", + deps: []entity.Batch{ + {ID: "q/batch/3"}, + {ID: "q/batch/1"}, + {ID: "q/batch/2"}, + }, + want: []entity.SpeculationPath{{ + Base: []string{"q/batch/3", "q/batch/1", "q/batch/2"}, + Head: "q/batch/4", + }}, + }, + } + + e := New() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := e.Enumerate(context.Background(), entity.Batch{ID: tt.batchID}, tt.deps) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + require.Len(t, got, 1) + assert.Equal(t, tt.batchID, got[0].Head) + }) + } +} diff --git a/submitqueue/extension/speculation/prioritizationlimit/static/BUILD.bazel b/submitqueue/extension/speculation/prioritizationlimit/static/BUILD.bazel new file mode 100644 index 00000000..ca20be37 --- /dev/null +++ b/submitqueue/extension/speculation/prioritizationlimit/static/BUILD.bazel @@ -0,0 +1,19 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["static.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit/static", + visibility = ["//visibility:public"], + deps = ["//submitqueue/extension/speculation/prioritizationlimit:go_default_library"], +) + +go_test( + name = "go_default_test", + srcs = ["static_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/prioritizationlimit/static/README.md b/submitqueue/extension/speculation/prioritizationlimit/static/README.md new file mode 100644 index 00000000..9316af92 --- /dev/null +++ b/submitqueue/extension/speculation/prioritizationlimit/static/README.md @@ -0,0 +1,3 @@ +# Static Prioritization Limit + +The static `prioritizationlimit.PrioritizationLimit` is constructed with a single integer and returns it, unchanged, from every call to `Limit`. It never errors and never consults any external signal — the simplest possible prioritization limit, useful for wiring tests, local development, and any queue whose concurrent-build budget is a fixed operational constant rather than one derived from live CI capacity or cost signals. diff --git a/submitqueue/extension/speculation/prioritizationlimit/static/static.go b/submitqueue/extension/speculation/prioritizationlimit/static/static.go new file mode 100644 index 00000000..8f634b7a --- /dev/null +++ b/submitqueue/extension/speculation/prioritizationlimit/static/static.go @@ -0,0 +1,42 @@ +// 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 static provides a prioritizationlimit.PrioritizationLimit that +// always returns a fixed, construction-time value. +package static + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit" +) + +// staticLimit is a prioritizationlimit.PrioritizationLimit that always +// returns a fixed value. +type staticLimit struct { + // limit is the fixed concurrent-build budget returned by every call to + // Limit. + limit int +} + +// New returns a prioritizationlimit.PrioritizationLimit whose Limit always +// returns limit. +func New(limit int) prioritizationlimit.PrioritizationLimit { + return staticLimit{limit: limit} +} + +// Limit returns the fixed value given to New. It never errors. +func (l staticLimit) Limit(_ context.Context) (int, error) { + return l.limit, nil +} diff --git a/submitqueue/extension/speculation/prioritizationlimit/static/static_test.go b/submitqueue/extension/speculation/prioritizationlimit/static/static_test.go new file mode 100644 index 00000000..78ed2362 --- /dev/null +++ b/submitqueue/extension/speculation/prioritizationlimit/static/static_test.go @@ -0,0 +1,35 @@ +// 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 static + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLimit_ReturnsConfiguredValue(t *testing.T) { + got, err := New(5).Limit(context.Background()) + require.NoError(t, err) + assert.Equal(t, 5, got) +} + +func TestLimit_ZeroValue(t *testing.T) { + got, err := New(0).Limit(context.Background()) + require.NoError(t, err) + assert.Equal(t, 0, got) +} diff --git a/submitqueue/extension/speculation/prioritizer/sticky/BUILD.bazel b/submitqueue/extension/speculation/prioritizer/sticky/BUILD.bazel new file mode 100644 index 00000000..6d70c364 --- /dev/null +++ b/submitqueue/extension/speculation/prioritizer/sticky/BUILD.bazel @@ -0,0 +1,25 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["sticky.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer/sticky", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/speculation/prioritizationlimit:go_default_library", + "//submitqueue/extension/speculation/prioritizer:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["sticky_test.go"], + embed = [":go_default_library"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/speculation/prioritizationlimit/fake: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/prioritizer/sticky/README.md b/submitqueue/extension/speculation/prioritizer/sticky/README.md new file mode 100644 index 00000000..059ea1ca --- /dev/null +++ b/submitqueue/extension/speculation/prioritizer/sticky/README.md @@ -0,0 +1,5 @@ +# Sticky Prioritizer + +The sticky `prioritizer.Prioritizer` implements sticky build slots: every candidate path that is already `Prioritized` or `Building` holds a slot it keeps until it resolves. It counts those, subtracts the count from the queue's current prioritization limit to get the free budget, and floors the result at zero rather than going negative when the queue is already over budget. It then ranks every `Selected` candidate — the ones asking for a slot — by descending `Score`, and admits as many as the free budget allows with a `Promote` decision each, in ranked order. Ties on `Score` are broken deterministically, first by the path's `Head` batch ID and then by its joined `Base`, so repeated rounds over the same input always order the same way. + +Candidates in any other status, and any `Selected` candidate beyond the free budget, are simply omitted — sticky prioritization never emits `Cancel`. This makes it the least disruptive prioritization policy: a lower-scored path that is already running is never evicted to make room for a newer, higher-scored one; the newer path instead waits until a slot frees up naturally. It is constructed with its `prioritizationlimit.PrioritizationLimit`, consulting it fresh on every call since the budget can change between rounds. diff --git a/submitqueue/extension/speculation/prioritizer/sticky/sticky.go b/submitqueue/extension/speculation/prioritizer/sticky/sticky.go new file mode 100644 index 00000000..1fd285fa --- /dev/null +++ b/submitqueue/extension/speculation/prioritizer/sticky/sticky.go @@ -0,0 +1,101 @@ +// 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 sticky provides a prioritizer.Prioritizer implementing sticky +// build slots: a running path keeps its slot until it resolves, and only the +// budget left over is handed out to pending candidates. It never preempts a +// running path to admit a higher-scored one. +package sticky + +import ( + "context" + "sort" + "strings" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit" + "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer" +) + +// stickyPrioritizer is a prioritizer.Prioritizer that never emits Cancel: it +// only fills whatever budget running paths have not already claimed. +type stickyPrioritizer struct { + // limit reports the queue's current concurrent-build budget. It is + // consulted on every Prioritize call rather than cached, since the value + // may change between calls. + limit prioritizationlimit.PrioritizationLimit +} + +// New returns a prioritizer.Prioritizer implementing sticky build slots: it +// never emits Cancel, and admits Selected candidates by descending Score into +// whatever budget limit leaves free once running paths are accounted for. +func New(limit prioritizationlimit.PrioritizationLimit) prioritizer.Prioritizer { + return &stickyPrioritizer{limit: limit} +} + +// Prioritize counts candidates already holding a slot (Prioritized or +// Building) against the queue's prioritization limit to compute the +// remaining free budget, then admits the free budget's worth of Selected +// candidates, highest Score first, with a Promote decision (naming the path by +// its ID) each. Ties break +// on Path.Head, then on the base joined with commas, so repeated rounds over +// the same input are stable. Candidates in any other status, and any Selected +// candidate beyond the free budget, are left as-is: sticky prioritization +// never cancels a running path. +func (p *stickyPrioritizer) Prioritize(ctx context.Context, candidates []entity.SpeculationPathInfo) ([]entity.PathDecision, error) { + used := 0 + var pending []entity.SpeculationPathInfo + for _, c := range candidates { + switch c.Status { + case entity.SpeculationPathStatusPrioritized, entity.SpeculationPathStatusBuilding: + used++ + case entity.SpeculationPathStatusSelected: + pending = append(pending, c) + } + } + + limit, err := p.limit.Limit(ctx) + if err != nil { + return nil, err + } + free := limit - used + if free < 0 { + free = 0 + } + if free == 0 || len(pending) == 0 { + return nil, nil + } + + sort.SliceStable(pending, func(i, j int) bool { + if pending[i].Score != pending[j].Score { + return pending[i].Score > pending[j].Score + } + if pending[i].Path.Head != pending[j].Path.Head { + return pending[i].Path.Head < pending[j].Path.Head + } + return strings.Join(pending[i].Path.Base, ",") < strings.Join(pending[j].Path.Base, ",") + }) + + if free > len(pending) { + free = len(pending) + } + decisions := make([]entity.PathDecision, free) + for i := 0; i < free; i++ { + decisions[i] = entity.PathDecision{ + PathID: pending[i].ID, + Action: entity.SpeculationPathActionPromote, + } + } + return decisions, nil +} diff --git a/submitqueue/extension/speculation/prioritizer/sticky/sticky_test.go b/submitqueue/extension/speculation/prioritizer/sticky/sticky_test.go new file mode 100644 index 00000000..ef839a0b --- /dev/null +++ b/submitqueue/extension/speculation/prioritizer/sticky/sticky_test.go @@ -0,0 +1,107 @@ +// 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 sticky + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/submitqueue/entity" + limitfake "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit/fake" +) + +func path(head string, base ...string) entity.SpeculationPath { + return entity.SpeculationPath{Base: base, Head: head} +} + +func TestPrioritize_AdmitsTopScoredSelectedIntoFreeSlots(t *testing.T) { + low := entity.SpeculationPathInfo{ID: "p/low", Path: path("q/batch/1"), Status: entity.SpeculationPathStatusSelected, Score: 0.4} + high := entity.SpeculationPathInfo{ID: "p/high", Path: path("q/batch/2"), Status: entity.SpeculationPathStatusSelected, Score: 0.9} + mid := entity.SpeculationPathInfo{ID: "p/mid", Path: path("q/batch/3"), Status: entity.SpeculationPathStatusSelected, Score: 0.6} + + p := New(limitfake.New(2)) + got, err := p.Prioritize(context.Background(), []entity.SpeculationPathInfo{low, high, mid}) + require.NoError(t, err) + assert.Equal(t, []entity.PathDecision{ + {PathID: high.ID, Action: entity.SpeculationPathActionPromote}, + {PathID: mid.ID, Action: entity.SpeculationPathActionPromote}, + }, got) +} + +func TestPrioritize_RunningPathsCountAgainstBudget(t *testing.T) { + building := entity.SpeculationPathInfo{ID: "p/building", Path: path("q/batch/1"), Status: entity.SpeculationPathStatusBuilding, Score: 0.99} + prioritized := entity.SpeculationPathInfo{ID: "p/prioritized", Path: path("q/batch/2"), Status: entity.SpeculationPathStatusPrioritized, Score: 0.99} + pending := entity.SpeculationPathInfo{ID: "p/pending", Path: path("q/batch/3"), Status: entity.SpeculationPathStatusSelected, Score: 0.5} + + // limit 3, 2 already running -> exactly 1 free slot for the pending candidate. + p := New(limitfake.New(3)) + got, err := p.Prioritize(context.Background(), []entity.SpeculationPathInfo{building, prioritized, pending}) + require.NoError(t, err) + assert.Equal(t, []entity.PathDecision{ + {PathID: pending.ID, Action: entity.SpeculationPathActionPromote}, + }, got) +} + +func TestPrioritize_ZeroFreeYieldsNoDecisions(t *testing.T) { + building := entity.SpeculationPathInfo{ID: "p/building", Path: path("q/batch/1"), Status: entity.SpeculationPathStatusBuilding, Score: 0.9} + pending := entity.SpeculationPathInfo{ID: "p/pending", Path: path("q/batch/2"), Status: entity.SpeculationPathStatusSelected, Score: 0.9} + + p := New(limitfake.New(1)) + got, err := p.Prioritize(context.Background(), []entity.SpeculationPathInfo{building, pending}) + require.NoError(t, err) + assert.Empty(t, got) + + // Also verify no Selected candidates at all yields no decisions. + got, err = p.Prioritize(context.Background(), []entity.SpeculationPathInfo{building}) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestPrioritize_TieBreakIsDeterministic(t *testing.T) { + a := entity.SpeculationPathInfo{ID: "p/a", Path: path("q/batch/2"), Status: entity.SpeculationPathStatusSelected, Score: 0.5} + b := entity.SpeculationPathInfo{ID: "p/b", Path: path("q/batch/1"), Status: entity.SpeculationPathStatusSelected, Score: 0.5} + c := entity.SpeculationPathInfo{ID: "p/c", Path: path("q/batch/1", "q/batch/0"), Status: entity.SpeculationPathStatusSelected, Score: 0.5} + + p := New(limitfake.New(3)) + + // Same score across all three: ties break on Head, then on Base. Running + // this repeatedly with inputs in different orders must always produce the + // same output order. + for _, in := range [][]entity.SpeculationPathInfo{ + {a, b, c}, + {c, b, a}, + {b, c, a}, + } { + got, err := p.Prioritize(context.Background(), in) + require.NoError(t, err) + assert.Equal(t, []entity.PathDecision{ + {PathID: b.ID, Action: entity.SpeculationPathActionPromote}, // q/batch/1, base [] + {PathID: c.ID, Action: entity.SpeculationPathActionPromote}, // q/batch/1, base [q/batch/0] + {PathID: a.ID, Action: entity.SpeculationPathActionPromote}, // q/batch/2 + }, got) + } +} + +func TestPrioritize_LimitErrorPropagates(t *testing.T) { + sentinel := errors.New("boom") + p := New(limitfake.New(3).FailWith(sentinel)) + _, err := p.Prioritize(context.Background(), []entity.SpeculationPathInfo{ + {ID: "p/1", Path: path("q/batch/1"), Status: entity.SpeculationPathStatusSelected, Score: 0.5}, + }) + require.ErrorIs(t, err, sentinel) +} diff --git a/submitqueue/extension/speculation/selector/all/BUILD.bazel b/submitqueue/extension/speculation/selector/all/BUILD.bazel new file mode 100644 index 00000000..bb478718 --- /dev/null +++ b/submitqueue/extension/speculation/selector/all/BUILD.bazel @@ -0,0 +1,23 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["all.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selector/all", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/speculation/selector:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["all_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/all/README.md b/submitqueue/extension/speculation/selector/all/README.md new file mode 100644 index 00000000..7f95beb3 --- /dev/null +++ b/submitqueue/extension/speculation/selector/all/README.md @@ -0,0 +1,5 @@ +# All Selector + +The all `selector.Selector` scans a batch's speculation tree and returns a Promote decision for every path currently in the Candidate status, in tree order — it builds every candidate path, maximum parallelism at maximum build cost. It has no opinion on any path in another status — Selected, Prioritized, Building, or a terminal status — and simply omits those, leaving them as-is for the controller. If the tree has no candidate paths, it returns no decisions. + +This is the least discriminating selection policy: it never narrows the candidate set, so it always asks to build every path the enumerator produced for a batch. It is the baseline and the counterpart to the queue-wide prioritizer, which is where an actual budget is enforced — the all selector expresses maximal per-batch desire, and the prioritizer (see the [prioritizer](../../prioritizer) extension, such as its `sticky` implementation) reconciles that desire against the queue's shared build capacity. diff --git a/submitqueue/extension/speculation/selector/all/all.go b/submitqueue/extension/speculation/selector/all/all.go new file mode 100644 index 00000000..b81073ab --- /dev/null +++ b/submitqueue/extension/speculation/selector/all/all.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 all provides a selector.Selector that promotes every Candidate +// path in a batch's speculation tree — maximum parallelism, maximum build +// cost. It is a baseline policy: build every candidate the enumerator +// produced rather than picking a subset. +package all + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/speculation/selector" +) + +// allSelector is a selector.Selector that promotes every Candidate path. +type allSelector struct{} + +// New returns a selector.Selector that promotes every Candidate path and +// leaves every other path as-is. +func New() selector.Selector { + return allSelector{} +} + +// Select returns a Promote decision, in tree order, for every path whose +// Status is Candidate, each naming its path by ID. Paths in any other status +// are omitted. +func (allSelector) Select(_ context.Context, tree entity.SpeculationTree) ([]entity.PathDecision, error) { + var decisions []entity.PathDecision + for _, p := range tree.Paths { + if p.Status != entity.SpeculationPathStatusCandidate { + continue + } + decisions = append(decisions, entity.PathDecision{ + PathID: p.ID, + Action: entity.SpeculationPathActionPromote, + }) + } + return decisions, nil +} diff --git a/submitqueue/extension/speculation/selector/all/all_test.go b/submitqueue/extension/speculation/selector/all/all_test.go new file mode 100644 index 00000000..76ba93b9 --- /dev/null +++ b/submitqueue/extension/speculation/selector/all/all_test.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 all + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/submitqueue/entity" +) + +func TestSelect(t *testing.T) { + candidatePath := entity.SpeculationPath{Head: "q/batch/1"} + selectedPath := entity.SpeculationPath{Base: []string{"q/batch/0"}, Head: "q/batch/1"} + passedPath := entity.SpeculationPath{Head: "q/batch/2"} + + tests := []struct { + name string + paths []entity.SpeculationPathInfo + want []entity.PathDecision + }{ + { + name: "mixed status tree promotes only candidates", + paths: []entity.SpeculationPathInfo{ + {ID: "p/candidate", Path: candidatePath, Status: entity.SpeculationPathStatusCandidate}, + {ID: "p/selected", Path: selectedPath, Status: entity.SpeculationPathStatusSelected}, + {ID: "p/passed", Path: passedPath, Status: entity.SpeculationPathStatusPassed}, + }, + want: []entity.PathDecision{ + {PathID: "p/candidate", Action: entity.SpeculationPathActionPromote}, + }, + }, + { + name: "no candidates yields no decisions", + paths: []entity.SpeculationPathInfo{ + {ID: "p/selected", Path: selectedPath, Status: entity.SpeculationPathStatusSelected}, + {ID: "p/passed", Path: passedPath, Status: entity.SpeculationPathStatusPassed}, + }, + want: nil, + }, + } + + s := New() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tree := entity.SpeculationTree{BatchID: "q/batch/1", Paths: tt.paths} + got, err := s.Select(context.Background(), tree) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +}