From 0049b73d1287c6f109d3df37a1be80fa7c40bcb0 Mon Sep 17 00:00:00 2001 From: Preetam Dwivedi Date: Fri, 24 Jul 2026 10:53:04 -0700 Subject: [PATCH] feat(speculation): allocator contract and sticky impl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add submitqueue/extension/speculation/allocator, the budget-spending composition point (Allocator) the standard Speculator hands its candidate stream to, plus the sticky implementation and mocks. The Allocator owns terminal-path suppression: the generator enumerates the whole path space with no knowledge of the path sets, so paths whose builds already ran come back round as candidates. Allocate reconciles the stream against the path sets by path ID — an in-flight candidate keeps the slot it already holds rather than starting a second attempt, and a terminal one is skipped. Neither draws on free budget. Budget accounting reads path status and nothing else. Pending, building, and cancelling paths hold a slot; terminal ones do not. No batch state enters the decision — merging and the rest are states of a batch, never of a path — because the budget tracks builds occupying CI, and a cancelling build occupies CI until it actually stops. The budget is measured in concurrent builds: one slot per path, whatever that build's size. That unit is deliberately coarse — an allocator that understands build size (target count, historical cost) could weight paths instead; the contract leaves the unit to the implementation. Allocation honors context cancellation, returning the context error and no actions. The output is all-or-nothing on purpose: a partial action list would fund an arbitrary prefix of the ranking, leaving the budget half-spent on whatever was pulled first. sticky checks before its first pull and again on each iteration, so a generator that ignores its own context cannot spin the loop. sticky fills only free budget slots and never preempts a running build, trading responsiveness for never discarding work already started; its policy counterpart is a preempting allocator. A stored path with no status is corrupt data and sticky fails fast — Allocate returns an error rather than guessing whether the record holds a slot — which also removes its only logging dependency, so New takes just the budget. Tests cover a terminal path arriving as the top-ranked candidate for each terminal status (passed, failed, cancelled): it is skipped and the slot goes to the next eligible candidate. Contract docs live in allocator/README.md; sticky's behavior is documented next to the code in sticky/README.md. --- .../speculation/allocator/BUILD.bazel | 12 + .../extension/speculation/allocator/README.md | 13 + .../speculation/allocator/allocator.go | 44 ++++ .../speculation/allocator/mock/BUILD.bazel | 13 + .../allocator/mock/allocator_mock.go | 58 ++++ .../speculation/allocator/sticky/BUILD.bazel | 24 ++ .../speculation/allocator/sticky/README.md | 11 + .../speculation/allocator/sticky/sticky.go | 133 ++++++++++ .../allocator/sticky/sticky_test.go | 247 ++++++++++++++++++ 9 files changed, 555 insertions(+) create mode 100644 submitqueue/extension/speculation/allocator/BUILD.bazel create mode 100644 submitqueue/extension/speculation/allocator/README.md create mode 100644 submitqueue/extension/speculation/allocator/allocator.go create mode 100644 submitqueue/extension/speculation/allocator/mock/BUILD.bazel create mode 100644 submitqueue/extension/speculation/allocator/mock/allocator_mock.go create mode 100644 submitqueue/extension/speculation/allocator/sticky/BUILD.bazel create mode 100644 submitqueue/extension/speculation/allocator/sticky/README.md create mode 100644 submitqueue/extension/speculation/allocator/sticky/sticky.go create mode 100644 submitqueue/extension/speculation/allocator/sticky/sticky_test.go diff --git a/submitqueue/extension/speculation/allocator/BUILD.bazel b/submitqueue/extension/speculation/allocator/BUILD.bazel new file mode 100644 index 00000000..f1003432 --- /dev/null +++ b/submitqueue/extension/speculation/allocator/BUILD.bazel @@ -0,0 +1,12 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["allocator.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/allocator", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/speculation/generator:go_default_library", + ], +) diff --git a/submitqueue/extension/speculation/allocator/README.md b/submitqueue/extension/speculation/allocator/README.md new file mode 100644 index 00000000..550e1ee0 --- /dev/null +++ b/submitqueue/extension/speculation/allocator/README.md @@ -0,0 +1,13 @@ +# allocator + +The `allocator` package defines a composition point used by the `standard` `Speculator`: an `Allocator` spends the queue's build budget over a candidate iterator. Like `generator`, it is **not** a controller-facing extension — an alternate `Speculator` need not use or expose this split — so there is no `Config` or `Factory` here; an `Allocator` is chosen when the `standard` `Speculator` is constructed. + +`Allocate` pulls candidates in the order the iterator yields them and matches them against the queue's current path sets by path ID. A pending or building path keeps the slot it already holds rather than starting a second attempt. A path whose build already finished comes back round as a candidate — the generator enumerates the whole space — and is skipped. Neither draws on free budget. + +Pending, building, and cancelling paths all charge the budget; a cancelling build holds its slot until it reaches a terminal state. Terminal paths charge nothing. `Allocate` returns the build and cancel actions that spend what is left. + +A cancelled or expired context aborts the run with its error and no actions. The output is all-or-nothing on purpose: a partial list would fund an arbitrary prefix of the ranking, leaving the budget half-spent on whatever was pulled first. + +Because cancellation is best-effort, an `Allocator` should not spend capacity it only expects a cancel to release. A build cancelled to make room keeps charging the budget until that cancel reaches a terminal state, so the queue converges over successive runs instead of oversubscribing the hard cap in one pass. + +How the budget is measured is the implementation's choice. The simplest unit is a build: every path costs one slot, whatever its build does. An `Allocator` that understands build size — target count, historical cost — could weight paths instead and pack the budget more tightly. diff --git a/submitqueue/extension/speculation/allocator/allocator.go b/submitqueue/extension/speculation/allocator/allocator.go new file mode 100644 index 00000000..747733d3 --- /dev/null +++ b/submitqueue/extension/speculation/allocator/allocator.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 allocator defines the budget-spending composition point used by the +// standard Speculator. An Allocator spends the queue's build budget over a +// candidate iterator. It is not a controller-facing extension — an alternate +// Speculator need not use or expose this split. +package allocator + +//go:generate mockgen -source=allocator.go -destination=mock/allocator_mock.go -package=mock + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/speculation/generator" +) + +// Allocator decides how to spend the queue's build budget over the generator's +// candidate stream, returning the build and cancel actions to take this run. How +// it rations the budget across new candidates and paths already in flight — and +// whether it preempts running builds — is the implementation's policy. +type Allocator interface { + // Allocate returns the build and cancel actions to take. It reconciles the + // candidate stream against the queue's current path sets by path ID: a + // candidate matching an in-flight path stays funded rather than starting a + // new attempt, and one matching a path whose build already finished is + // skipped — the generator enumerates the whole space, so suppressing + // finished paths is the Allocator's job. + // + // A cancelled or expired ctx aborts with its error and no actions. + Allocate(ctx context.Context, pathSets []entity.SpeculationPathSet, iter generator.PathIterator) ([]entity.Speculation, error) +} diff --git a/submitqueue/extension/speculation/allocator/mock/BUILD.bazel b/submitqueue/extension/speculation/allocator/mock/BUILD.bazel new file mode 100644 index 00000000..2e703953 --- /dev/null +++ b/submitqueue/extension/speculation/allocator/mock/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["allocator_mock.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/allocator/mock", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/speculation/generator:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + ], +) diff --git a/submitqueue/extension/speculation/allocator/mock/allocator_mock.go b/submitqueue/extension/speculation/allocator/mock/allocator_mock.go new file mode 100644 index 00000000..fd5c8e1f --- /dev/null +++ b/submitqueue/extension/speculation/allocator/mock/allocator_mock.go @@ -0,0 +1,58 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: allocator.go +// +// Generated by this command: +// +// mockgen -source=allocator.go -destination=mock/allocator_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" + generator "github.com/uber/submitqueue/submitqueue/extension/speculation/generator" + gomock "go.uber.org/mock/gomock" +) + +// MockAllocator is a mock of Allocator interface. +type MockAllocator struct { + ctrl *gomock.Controller + recorder *MockAllocatorMockRecorder + isgomock struct{} +} + +// MockAllocatorMockRecorder is the mock recorder for MockAllocator. +type MockAllocatorMockRecorder struct { + mock *MockAllocator +} + +// NewMockAllocator creates a new mock instance. +func NewMockAllocator(ctrl *gomock.Controller) *MockAllocator { + mock := &MockAllocator{ctrl: ctrl} + mock.recorder = &MockAllocatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAllocator) EXPECT() *MockAllocatorMockRecorder { + return m.recorder +} + +// Allocate mocks base method. +func (m *MockAllocator) Allocate(ctx context.Context, pathSets []entity.SpeculationPathSet, iter generator.PathIterator) ([]entity.Speculation, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Allocate", ctx, pathSets, iter) + ret0, _ := ret[0].([]entity.Speculation) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Allocate indicates an expected call of Allocate. +func (mr *MockAllocatorMockRecorder) Allocate(ctx, pathSets, iter any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Allocate", reflect.TypeOf((*MockAllocator)(nil).Allocate), ctx, pathSets, iter) +} diff --git a/submitqueue/extension/speculation/allocator/sticky/BUILD.bazel b/submitqueue/extension/speculation/allocator/sticky/BUILD.bazel new file mode 100644 index 00000000..e8f9c854 --- /dev/null +++ b/submitqueue/extension/speculation/allocator/sticky/BUILD.bazel @@ -0,0 +1,24 @@ +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/allocator/sticky", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/speculation/allocator:go_default_library", + "//submitqueue/extension/speculation/generator:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["sticky_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/allocator/sticky/README.md b/submitqueue/extension/speculation/allocator/sticky/README.md new file mode 100644 index 00000000..b1e37723 --- /dev/null +++ b/submitqueue/extension/speculation/allocator/sticky/README.md @@ -0,0 +1,11 @@ +# sticky + +The `sticky` `Allocator` fills only free budget slots and leaves every in-flight build running. Against the budget it counts the paths that still hold a slot: `pending`, `building`, and `cancelling` — a cancel is only a request, and the build keeps its slot until it actually stops. Only path status enters this — no batch state does, since the budget tracks builds occupying CI. + +It then proposes a build for each new candidate, in order, until the budget fills. Two kinds of candidate are skipped, for different reasons. A candidate matching an already-funded path is holding a slot already — it is counted above — so it needs no additional slot and no second build action. A candidate whose path is terminal in the path sets holds no slot and needs none. Neither consumes free budget. It never proposes a cancel. + +The budget is measured in builds: one slot per path, no matter what that path's build does. That is deliberately coarse — a twelve-hour build and a two-minute build cost the same slot. An allocator that understands build size (target count, historical duration) could pack the same budget more effectively; `sticky` trades that for simplicity. + +A stored path with no status at all is a data defect, and `sticky` fails fast: `Allocate` returns an error rather than guessing what the record means. + +The trade-off: a better candidate arriving while the budget is full waits for a running build to finish rather than displacing it. `sticky` never discards work already started, and it cannot react to a newly attractive path mid-flight. Its counterpart is a preempting `Allocator` that cancels a low-value in-flight path to fund a better one, spending a cancel now to converge faster on the next run. diff --git a/submitqueue/extension/speculation/allocator/sticky/sticky.go b/submitqueue/extension/speculation/allocator/sticky/sticky.go new file mode 100644 index 00000000..601541b5 --- /dev/null +++ b/submitqueue/extension/speculation/allocator/sticky/sticky.go @@ -0,0 +1,133 @@ +// 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 an allocator.Allocator that fills only free budget +// slots and leaves every in-flight build running — it never preempts. Its policy +// counterpart is a preempting allocator, which would cancel a low-value in-flight +// path to fund a better candidate; sticky trades that responsiveness for never +// discarding a build it has already started. +package sticky + +import ( + "context" + "fmt" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/speculation/allocator" + "github.com/uber/submitqueue/submitqueue/extension/speculation/generator" +) + +// alloc is a sticky allocator.Allocator. +type alloc struct { + // budget is the queue's cap on concurrently held build slots, measured in + // builds: one slot per path whose attempt is pending, building, or + // cancelling, whatever that build's size. The unit is deliberately coarse — + // an allocator that understands build size could weight paths instead. + budget int +} + +// New returns a sticky allocator.Allocator with the given budget, measured in +// concurrent builds. +func New(budget int) allocator.Allocator { + return alloc{budget: budget} +} + +// Allocate keeps every in-flight path funded and pulls candidates in order into +// the remaining free budget, proposing a build for each new path until the +// budget fills. It never proposes a cancel. +// +// A cancelled or expired ctx aborts with its error and no actions. The run's +// output is all-or-nothing: a partial action list would fund an arbitrary +// prefix of the ranking, so a caller that gave up mid-run gets nothing rather +// than a half-spent budget. +func (a alloc) Allocate(ctx context.Context, pathSets []entity.SpeculationPathSet, iter generator.PathIterator) ([]entity.Speculation, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + // Paths already holding a build slot. This set does double duty: it is both + // the count of budget already spent and the guard against proposing a second + // build for something already running, so a status belongs here if either + // reason applies. + funded := make(map[string]bool) + // Paths whose build already finished. They hold no slot, but they must not + // be built again either. + terminal := make(map[string]bool) + for _, set := range pathSets { + for _, entry := range set.Paths { + if entry.Status == entity.SpeculationPathStatusUnknown { + // A stored path always carries a real status, so this record is + // corrupt. Fail fast rather than guess: any reading of it — slot + // held or free, buildable or not — could be wrong in a way that + // either pins a slot forever or double-builds a path. + return nil, fmt.Errorf("speculation path %q of head %q has no status", entry.ID, set.Head) + } + if entry.Status.IsTerminal() { + terminal[entry.ID] = true + continue + } + // Anything else that has not finished is still holding a slot: + // pending and building obviously, and cancelling too, since the build + // keeps its slot until it actually stops. + // + // Only the path's status matters. No batch state enters this + // decision — "merging" and the rest are states of a batch, never of + // a path — so the rule is simply that CI is still busy with it. + funded[entry.ID] = true + } + } + + free := a.budget - len(funded) + + var out []entity.Speculation + proposed := make(map[string]bool) + for free > 0 { + // Next reports cancellation too, but check here as well: a generator + // that ignores ctx must not be able to spin this loop forever. + if err := ctx.Err(); err != nil { + return nil, err + } + c, ok, err := iter.Next(ctx) + if err != nil { + return nil, err + } + if !ok { + // ok=false means the generator has nothing left to offer: every path + // the queue could speculate on has been seen. Stopping here with + // budget still free is normal, not a failure — there is simply + // nothing else worth building right now. + break + } + id := c.Path.ID() + if terminal[id] { + // This path's build already ran. The generator enumerates the whole + // space with no knowledge of the path sets, so finished paths come + // back round as candidates; they need no slot and must not be rebuilt. + continue + } + if funded[id] || proposed[id] { + // funded: this path is already building. It needs no new action and + // is already charging the budget, so skip it without spending + // anything. + // + // proposed: a generator is not supposed to repeat a candidate, so + // this should never fire. It is a cheap guard against one that does, + // since a duplicate would otherwise be paid for twice. + continue + } + out = append(out, entity.Speculation{Path: c.Path, Action: entity.PathActionBuild}) + proposed[id] = true + free-- + } + return out, nil +} diff --git a/submitqueue/extension/speculation/allocator/sticky/sticky_test.go b/submitqueue/extension/speculation/allocator/sticky/sticky_test.go new file mode 100644 index 00000000..24d3519a --- /dev/null +++ b/submitqueue/extension/speculation/allocator/sticky/sticky_test.go @@ -0,0 +1,247 @@ +// 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" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/submitqueue/entity" +) + +// fakeIter yields a fixed slice of candidates, optionally failing on the first pull. +type fakeIter struct { + items []entity.CandidatePath + idx int + fail bool +} + +func (f *fakeIter) Next(context.Context) (entity.CandidatePath, bool, error) { + if f.fail { + return entity.CandidatePath{}, false, errors.New("iterator boom") + } + if f.idx >= len(f.items) { + return entity.CandidatePath{}, false, nil + } + c := f.items[f.idx] + f.idx++ + return c, true, nil +} + +// candidate builds a one-dependency candidate path for the given head and +// assumption. +func candidate(head string, assumption entity.DependencyAssumption, dep string) entity.CandidatePath { + return entity.CandidatePath{ + Path: entity.SpeculationPath{ + Head: head, + Dependencies: []entity.PathDependency{{Batch: dep, Assumption: assumption}}, + }, + } +} + +// fundedSet returns a path set holding one entry for head at the given status, +// whose ID matches candidate(head, assumption, dep) so it lines up with an +// iterator item. +func fundedSet(head string, assumption entity.DependencyAssumption, dep string, status entity.SpeculationPathStatus) entity.SpeculationPathSet { + c := candidate(head, assumption, dep) + return entity.SpeculationPathSet{ + Head: head, + Paths: []entity.SpeculationPathEntry{{ID: c.Path.ID(), Path: c.Path, Status: status}}, + } +} + +func TestSticky_Allocate(t *testing.T) { + tests := []struct { + name string + budget int + pathSets []entity.SpeculationPathSet + items []entity.CandidatePath + iterFail bool + wantErr bool + // wantIdx are indices into items expected to be proposed as builds, in order. + wantIdx []int + }{ + { + name: "fills free budget in order", + budget: 2, + items: []entity.CandidatePath{ + candidate("q/2", entity.DependencyAssumptionSucceeds, "q/1"), + candidate("q/2", entity.DependencyAssumptionFails, "q/1"), + candidate("q/3", entity.DependencyAssumptionSucceeds, "q/1"), + }, + wantIdx: []int{0, 1}, + }, + { + name: "keeps in-flight path funded and fills the free slot", + budget: 2, + pathSets: []entity.SpeculationPathSet{fundedSet("q/2", entity.DependencyAssumptionSucceeds, "q/1", entity.SpeculationPathStatusBuilding)}, + items: []entity.CandidatePath{ + candidate("q/2", entity.DependencyAssumptionSucceeds, "q/1"), // already funded -> skipped + candidate("q/2", entity.DependencyAssumptionFails, "q/1"), // fills the one free slot + }, + wantIdx: []int{1}, + }, + { + name: "terminal entry does not charge budget", + budget: 1, + pathSets: []entity.SpeculationPathSet{fundedSet("q/2", entity.DependencyAssumptionSucceeds, "q/1", entity.SpeculationPathStatusPassed)}, + items: []entity.CandidatePath{candidate("q/3", entity.DependencyAssumptionFails, "q/1")}, + wantIdx: []int{0}, + }, + { + // The generator enumerates the whole space, so a path whose build + // already ran comes back round as a candidate — even as the + // top-ranked one. It must be skipped without spending the slot, which + // then goes to the next eligible candidate. + name: "skips a passed top candidate without spending the slot", + budget: 1, + pathSets: []entity.SpeculationPathSet{fundedSet("q/2", entity.DependencyAssumptionSucceeds, "q/1", entity.SpeculationPathStatusPassed)}, + items: []entity.CandidatePath{ + candidate("q/2", entity.DependencyAssumptionSucceeds, "q/1"), // the passed path itself + candidate("q/2", entity.DependencyAssumptionFails, "q/1"), + }, + wantIdx: []int{1}, + }, + { + name: "skips a failed top candidate without spending the slot", + budget: 1, + pathSets: []entity.SpeculationPathSet{fundedSet("q/2", entity.DependencyAssumptionSucceeds, "q/1", entity.SpeculationPathStatusFailed)}, + items: []entity.CandidatePath{ + candidate("q/2", entity.DependencyAssumptionSucceeds, "q/1"), // the failed path itself + candidate("q/2", entity.DependencyAssumptionFails, "q/1"), + }, + wantIdx: []int{1}, + }, + { + name: "skips a cancelled top candidate without spending the slot", + budget: 1, + pathSets: []entity.SpeculationPathSet{fundedSet("q/2", entity.DependencyAssumptionSucceeds, "q/1", entity.SpeculationPathStatusCancelled)}, + items: []entity.CandidatePath{ + candidate("q/2", entity.DependencyAssumptionSucceeds, "q/1"), // the cancelled path itself + candidate("q/2", entity.DependencyAssumptionFails, "q/1"), + }, + wantIdx: []int{1}, + }, + { + name: "pending entry charges budget", + budget: 1, + pathSets: []entity.SpeculationPathSet{fundedSet("q/2", entity.DependencyAssumptionSucceeds, "q/1", entity.SpeculationPathStatusPending)}, + items: []entity.CandidatePath{candidate("q/3", entity.DependencyAssumptionSucceeds, "q/1")}, + wantIdx: nil, + }, + { + // Cancelling is non-terminal, so it still charges the budget: sticky + // must not spend a slot it only expects the cancel to free, which would + // oversubscribe the hard cap on concurrent builds. + name: "cancelling entry charges budget", + budget: 1, + pathSets: []entity.SpeculationPathSet{fundedSet("q/2", entity.DependencyAssumptionSucceeds, "q/1", entity.SpeculationPathStatusCancelling)}, + items: []entity.CandidatePath{candidate("q/3", entity.DependencyAssumptionSucceeds, "q/1")}, + wantIdx: nil, + }, + { + // A zero-valued status is a data defect: any reading of the record + // could be wrong, so sticky fails fast rather than guessing. + name: "entry with no status fails fast", + budget: 1, + pathSets: []entity.SpeculationPathSet{fundedSet("q/2", entity.DependencyAssumptionSucceeds, "q/1", entity.SpeculationPathStatusUnknown)}, + items: []entity.CandidatePath{candidate("q/3", entity.DependencyAssumptionSucceeds, "q/1")}, + wantErr: true, + }, + { + name: "deduplicates a repeated candidate", + budget: 5, + items: []entity.CandidatePath{ + candidate("q/2", entity.DependencyAssumptionSucceeds, "q/1"), + candidate("q/2", entity.DependencyAssumptionSucceeds, "q/1"), + }, + wantIdx: []int{0}, + }, + { + name: "propagates iterator error", + budget: 2, + iterFail: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + iter := &fakeIter{items: tt.items, fail: tt.iterFail} + got, err := New(tt.budget).Allocate(context.Background(), tt.pathSets, iter) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + require.Len(t, got, len(tt.wantIdx)) + for i, idx := range tt.wantIdx { + assert.Equal(t, entity.PathActionBuild, got[i].Action) + assert.Equal(t, tt.items[idx].Path.ID(), got[i].Path.ID()) + } + }) + } +} + +func TestSticky_HonorsCancelledContext(t *testing.T) { + // The run's output is all-or-nothing: a partial action list would fund an + // arbitrary prefix of the ranking, so cancellation yields no actions at all. + items := []entity.CandidatePath{ + candidate("q/2", entity.DependencyAssumptionSucceeds, "q/1"), + candidate("q/2", entity.DependencyAssumptionFails, "q/1"), + } + + t.Run("cancelled before the first pull", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + got, err := New(5).Allocate(ctx, nil, &fakeIter{items: items}) + require.ErrorIs(t, err, context.Canceled) + assert.Nil(t, got) + }) + + t.Run("expired deadline", func(t *testing.T) { + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Minute)) + defer cancel() + + got, err := New(5).Allocate(ctx, nil, &fakeIter{items: items}) + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.Nil(t, got) + }) + + t.Run("iterator that ignores cancellation cannot spin the loop", func(t *testing.T) { + // endlessIter never reports cancellation and never runs out, so the + // allocator's own check is the only thing that stops the pull loop. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + got, err := New(5).Allocate(ctx, nil, endlessIter{item: items[0]}) + require.ErrorIs(t, err, context.Canceled) + assert.Nil(t, got) + }) +} + +// endlessIter yields the same candidate forever and ignores ctx entirely. +type endlessIter struct{ item entity.CandidatePath } + +func (e endlessIter) Next(context.Context) (entity.CandidatePath, bool, error) { + return e.item, true, nil +}