Skip to content

Commit 9228d86

Browse files
committed
fix(speculation): stop one dependency failing the whole snapshot
## Summary ### Why? Speculation died for an entire queue whenever any batch depended on one that had reached `merging`: ``` speculator failed for queue demo-queue: score dependency "demo-queue/batch/1": failed to resolve storage for queue "": queue name must not be empty ``` The root cause is a caller contract violation, not a generator bug. `speculator.Speculate` documents `batches` as "every in-flight batch of the queue, plus any finalized batch still referenced as a dependency by an in-flight one". `ask` was passing `snap.speculating` — the speculating heads alone. `read` already assembles the right set in `snap.batches`; it simply was not the slice handed over. A merging dependency was therefore absent from the generator's index, `batchByID[id]` returned a zero `entity.Batch`, and that zero batch reached the scorer with an empty `Queue`. The blast radius was the whole queue rather than one batch, because `Generate` scores every unresolved dependency up front to seed its heap and returned on the first error. `standard.Speculate` then short-circuited before the allocator pulled a single candidate: no path failed, none was ever produced, and the fall-back-to-the-next-path machinery sits downstream of a stage that had already died. A measured run of 20 requests left 9 in `error` and 11 wedged in `speculating` with nothing recorded against them. ### What? **Hand the Speculator the whole queue.** `snapshot.batchesForSpeculator()` returns every batch the run read, sorted by ID so a plan never varies with map iteration order, and `ask` passes that. `ask`'s doc comment argued for the narrow slice and is rewritten. Widening only stays safe if the snapshot is honest about who is still open, because both the Speculator and `check` read head eligibility off that same map — a stale entry fools both filters at once. Two things had to change for that to hold: - `recordOutcome` now records `Merging` alongside the terminal states. It previously recorded terminal outcomes only, reasoning that merging resolves nothing for dependents — true, and every terminal-state check still treats a merging batch as unresolved. But a head this run had just handed to the merge stage went on reading as `Speculating`, so the Speculator would offer it and `check` would wave it through, funding a fresh build for a batch already on its way out of the queue. - A head whose outcome lost the compare-and-swap is withheld from the Speculator entirely. Its recorded state still says `Speculating` while another writer has moved it on, and this run cannot know where to — so it says nothing about it rather than something wrong. `finalize` already dropped such heads from the open list for exactly this reason; now the batch list agrees. **Make one unpriceable dependency cost only its own estimate.** `score` now substitutes `defaultProbability` when the scorer returns an error, and never calls the scorer at all for a dependency the snapshot did not carry — that batch is zero in every field, so scoring it would price some other batch entirely or fail on its empty queue name. Context cancellation is still fatal, including when it surfaces *as* the scorer's error: the loop checks `ctx` before each call, so a context that dies during the last one would otherwise be absorbed as an unpriceable dependency and hand back an iterator to a caller that has already gone. Scorer failures stay observable through the scorer's own metrics span, which already reports them via `op.Complete(retErr)`. That is the whole containment fix. An earlier revision of this branch also made scoring lazy — heads seeded at an optimistic bound and priced on first pull — and it has been dropped. The only admissible bound for an unpriced head is `log 1`, identical for every head, so the first pull priced the entire queue anyway; the laziness bought one narrow case (a run that pulls nothing because the budget is saturated) in exchange for a placeholder, an admissibility argument, priced and unpriced items sharing a heap, and five reworked tests. Defaulting on failure fixes the bug on its own. **`Merging` is left as an open question in the generator.** Tempting to pin it to *succeeds* — the batch looks committed to landing — but a merge can fail, so nothing is settled, and it would put a state-specific policy inside the search when whether a path betting against a merging batch is worth funding is a question of price that belongs to the scorer. The allocator already draws exactly this line — "no batch state enters this decision — `merging` and the rest are states of a batch, never of a path" — and the generator holds it too. ## Test Plan - ✅ `make test` — 96/96 pass - ✅ `make lint`, `make check-gazelle`, `make check-tidy` New and reworked coverage, per defect: - `TestRun_PassesSnapshotToSpeculator` — the Speculator receives every batch the run read, in a stable order - `TestBestFirst_AbsorbsScorerError` — a scorer error costs that dependency its estimate and nothing else - `TestBestFirst_NeverScoresAnAbsentDependency` — an absent dependency never reaches the scorer, even when the caller hands over a malformed snapshot - `TestBestFirst_MergingDependencyStaysOpen` — a merging dependency is priced like any other and keeps both sides - `TestRun_MergedHeadIsNoLongerOfferedAsOpen`, `TestRun_HeadThatLostTheOutcomeRaceIsWithheld` — the snapshot handed over never describes a head the run has closed as still open - `TestBestFirst_HonorsCancelledContext/a scorer that fails on a dead context ends the run` The last two were added for defects a review of this branch turned up; each was confirmed to fail against the code as it stood before its fix. Not verified end to end: `make demo-pr` lives on the `sq/demo-pr` branch, so reproducing the original 20-request run needs that target ported across worktrees plus a Docker stack. ## Issue Fixes https://linear.app/uber/issue/CODEM-424 That issue proposed lazy scoring as its primary fix; this lands the containment it was after without the algorithm change, for the reasons above. Follow-up filed as https://linear.app/uber/issue/CODEM-428 — this stops queues wedging this way, but a queue already wedged still has no event that will wake it, because speculation is edge-triggered only. A separate correctness defect in the merge gate — a head could merge on a *fails* assumption that had not come true — was found while investigating this one. It is stacked on top of this PR rather than folded in, since nothing here depends on it.
1 parent a0d6532 commit 9228d86

11 files changed

Lines changed: 258 additions & 49 deletions

File tree

doc/rfc/submitqueue/speculation-generator-best-first.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ The batch being built is written before its assumptions. For example, `C [A succ
4949

5050
`Generate` receives the queue's live batches as a snapshot and takes it as given. A well-formed snapshot carries unique, non-empty batch IDs, includes every batch a head's direct dependencies reference, and gives no head an empty, duplicate, or self dependency. Those are preconditions the caller owns, established where the snapshot is assembled. The generator does not re-check them: it is on the hot path of every run, the checks it could make are the ones an assembled-correctly snapshot can never fail, and paying for them here only spreads the same contract across two places. A malformed snapshot yields undefined candidates rather than an error.
5151

52-
A score that is not a probability is the one bad input the generator absorbs, because it arrives from the injected scorer rather than from the caller and there is no earlier point that could catch it. A score outside `[0, 1]`, or `NaN`, is replaced with a default of 0.95 — optimistic on purpose, so a dependency nobody could estimate keeps its head's preferred path near the front instead of burying it or failing the whole run on one number. Any deliberate defaulting still belongs to the scorer implementation, which knows what information it does and does not have; this is only the floor under it.
52+
A dependency the generator cannot price is the one bad input it absorbs, because it arrives from the injected scorer rather than from the caller and there is no earlier point that could catch it. Three cases take the same 0.95 default: a score outside `[0, 1]` or `NaN`, a scorer call that returned an error, and a dependency the snapshot never carried. The default is optimistic on purpose, so a dependency nobody could estimate keeps its head's preferred path near the front instead of burying it or failing the whole run on one number — and failing the whole run is the real hazard, because `Generate` seeds the heap for every head at once, so one unpriceable dependency would otherwise cost the queue every candidate it had. A batch absent from the snapshot is never passed to the scorer at all: it would resolve to a zero-valued batch belonging to no queue, so scoring it would price some other batch entirely or fail on the empty queue name. Any deliberate defaulting still belongs to the scorer implementation, which knows what information it does and does not have; this is only the floor under it.
5353

5454
## Step 1: `Generate` prepares each head
5555

@@ -447,6 +447,7 @@ The ordering stays the same. `CandidatePath.RankingScore` contains this logarith
447447
- `Succeeded` fixes an assumption to succeeds.
448448
- `Failed` or `Cancelled` fixes an assumption to fails.
449449
- `Cancelling` remains undecided because cancellation may lose a race with completion.
450+
- `Merging` also remains undecided, because a merge can fail. It is tempting to treat it as committed to landing and skip the scorer call, but that puts a state-specific policy inside the search: whether a path betting against a merging batch is worth funding is a question of price, and price belongs to the scorer. The allocator draws the same line — "no batch state enters this decision" — and the generator holds it too. Nothing is lost by staying open, because a head can never merge ahead of a dependency it took a position on (see [speculation.md](speculation.md)); the cost of an unlikely path is budget, which is the allocator's to ration.
450451
- A fixed assumption stays in the returned path but contributes probability 1 and has no flip.
451452
- A shared dependency is scored once per run.
452453

submitqueue/extension/speculation/generator/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
The `generator` package is a piece the `standard` `Speculator` is built from: a `Generator` produces the queue's candidate paths as one ordered stream across all heads. It is **not** controller-facing — the speculate controller only knows the `Speculator` contract, and a different `Speculator` need not split its work this way. So there is no `Config` or `Factory` here; a `Generator` is chosen when the `standard` `Speculator` is constructed.
44

5-
`Generate` starts the stream over the queue's live batches and returns an `Iterator`. Beyond any ranking work required up front, the generator computes only the candidates the caller pulls. A cancelled or expired context ends the stream with its error. The snapshot must include every batch a head's direct dependencies reference; a snapshot that does not — or that carries empty or duplicate IDs, or a head with an empty, duplicate, or self dependency — is malformed input and errors instead of opening a stream.
5+
`Generate` starts the stream over the queue's live batches and returns an `Iterator`. Beyond any ranking work required up front, the generator computes only the candidates the caller pulls. A cancelled or expired context ends the stream with its error. The snapshot must include every batch a head's direct dependencies reference, carry unique non-empty IDs, and give no head an empty, duplicate, or self dependency. Those are the caller's preconditions: a generator may assume them and is not required to detect a breach, so a malformed snapshot yields undefined candidates rather than an error.
66

77
Candidates never repeat and never contradict a known fact. Beyond that, the order is the `Generator`'s own: it yields candidates in whatever ranking it implements, and each carries the score it ranked by — higher first, on a scale the generator defines. Consumers take the iterator in the order given and do not interpret the score. Scores mean something only within the run and are never stored.
88

submitqueue/extension/speculation/generator/bestfirst/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ The [best-first speculation path generation RFC](../../../../../doc/rfc/submitqu
1010
- `Next` removes the highest-ranked candidate, advances only that head's stream, constructs that candidate's complete path, and returns it. Pulling long enough returns every path exactly once in non-increasing score order.
1111
- Ranking scores are sums of log probabilities, avoiding underflow while preserving probability order. They are meaningful only within the run that produced them.
1212
- Exact ties prefer fewer flips; head ID then decides between heads (the cross-head heap holds one candidate per head), and taken flip indexes decide within a head.
13-
- The snapshot must contain every batch a head's direct dependencies reference; a snapshot missing one — or carrying empty or duplicate batch IDs, or a head with an empty, duplicate, or self dependency — is malformed input and errors instead of opening a stream. Any defaulting for a batch that is hard to score belongs to the scorer, not the generator.
13+
- A dependency counts as resolved only once it is terminal. Merging and cancelling are both still in progress and either can end the other way, so both stay open questions here. Whether a path betting against a merging dependency is worth funding is a matter of price, and price is the scorer's to say.
14+
- A dependency that cannot be priced — the scorer call failed, the score was not a probability, or the snapshot never carried the batch — is treated as very likely to succeed rather than ending the run. One unusable number costs its own estimate, never the queue's whole set of candidates. A batch missing from the snapshot is never handed to the scorer at all: it would resolve to a zero batch belonging to no queue.
15+
- The snapshot must contain every batch a head's direct dependencies reference, carry unique non-empty batch IDs, and give no head an empty, duplicate, or self dependency. That is the caller's precondition, not something checked here: a malformed snapshot yields undefined candidates rather than an error.
1416

1517
The behavior is covered by `bestfirst_test.go`.

submitqueue/extension/speculation/generator/bestfirst/bestfirst.go

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import (
2525
"cmp"
2626
"container/heap"
2727
"context"
28-
"fmt"
2928
"maps"
3029
"math"
3130
"slices"
@@ -104,26 +103,47 @@ func speculatingHeads(batches []entity.Batch, batchByID map[string]entity.Batch)
104103

105104
// score asks the scorer for each unresolved dependency exactly once, however
106105
// many heads wait on it.
106+
//
107+
// A dependency that cannot be priced takes defaultProbability rather than
108+
// ending the run — one unusable number must not cost the queue every candidate
109+
// it had. Only cancellation is an error.
107110
func (g *bestFirst) score(ctx context.Context, ids []string, batchByID map[string]entity.Batch) (map[string]float64, error) {
108111
probabilityByID := make(map[string]float64, len(ids))
109112
for _, id := range ids {
110113
if err := ctx.Err(); err != nil {
111114
return nil, err
112115
}
113-
probability, err := g.scorer.Score(ctx, batchByID[id])
116+
batch, known := batchByID[id]
117+
if !known {
118+
// A batch the snapshot never carried is zero in every field, not
119+
// just missing — scoring it would price some other batch entirely,
120+
// or fail on its empty queue. It is unpriceable, not cheap.
121+
probabilityByID[id] = defaultProbability
122+
continue
123+
}
124+
probability, err := g.scorer.Score(ctx, batch)
114125
if err != nil {
115-
return nil, fmt.Errorf("score dependency %q: %w", id, err)
126+
// A scorer that failed because the caller went away has not found
127+
// an unpriceable dependency — it has found a dead ctx, which ends
128+
// the run. The loop's own check would not catch it on the last
129+
// dependency, and a cancelled Generate must never hand back an
130+
// iterator.
131+
if ctxErr := ctx.Err(); ctxErr != nil {
132+
return nil, ctxErr
133+
}
134+
probability = defaultProbability
116135
}
117136
probabilityByID[id] = asProbability(probability)
118137
}
119138
return probabilityByID, nil
120139
}
121140

122-
// defaultProbability stands in for a score that is not a probability. It is
123-
// optimistic on purpose: a dependency nobody could estimate is treated as very
124-
// likely to succeed, which keeps its head's preferred path near the front
125-
// rather than burying it or dropping the queue's whole snapshot on one bad
126-
// number.
141+
// defaultProbability stands in for a score that is not a probability, one the
142+
// scorer could not produce at all, and one for a dependency the snapshot never
143+
// carried. It is optimistic on purpose: a dependency nobody could estimate is
144+
// treated as very likely to succeed, which keeps its head's preferred path near
145+
// the front rather than burying it or dropping the queue's whole snapshot on
146+
// one bad number.
127147
const defaultProbability = 0.95
128148

129149
// asProbability keeps a usable score and substitutes the default for anything

submitqueue/extension/speculation/generator/bestfirst/bestfirst_test.go

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -381,15 +381,61 @@ func TestBestFirst_HeadWithNoDependencies(t *testing.T) {
381381
assert.InDelta(t, math.Log(1.0), cands[0].RankingScore, 1e-9)
382382
}
383383

384-
func TestBestFirst_PropagatesScorerError(t *testing.T) {
384+
// A scorer that cannot price a dependency costs that dependency its estimate,
385+
// nothing more. The queue keeps every candidate it had, ranked as if the
386+
// dependency were very likely to succeed.
387+
func TestBestFirst_AbsorbsScorerError(t *testing.T) {
385388
batches := []entity.Batch{
386389
{ID: "q/A", State: entity.BatchStateSpeculating},
387390
{ID: "q/H", State: entity.BatchStateSpeculating, Dependencies: []string{"q/A"}},
388391
}
389392

390393
iter, err := New(errScorer{}).Generate(context.Background(), batches)
391-
assert.Error(t, err)
392-
assert.Nil(t, iter)
394+
require.NoError(t, err)
395+
396+
cands := forHead(drainAll(t, iter), "q/H")
397+
require.Len(t, cands, 2)
398+
assert.Equal(t, entity.DependencyAssumptionSucceeds, assumptionFor(cands[0].Path, "q/A"))
399+
assert.InDelta(t, math.Log(defaultProbability), cands[0].RankingScore, 1e-9)
400+
}
401+
402+
// A dependency the snapshot never carried is unpriceable, not cheap: the zero
403+
// batch it would resolve to belongs to no queue, so it must never reach the
404+
// scorer.
405+
func TestBestFirst_NeverScoresAnAbsentDependency(t *testing.T) {
406+
batches := []entity.Batch{
407+
{ID: "q/H", State: entity.BatchStateSpeculating, Dependencies: []string{"q/missing"}},
408+
}
409+
sc := newCountingScorer(map[string]float64{})
410+
411+
iter, err := New(sc).Generate(context.Background(), batches)
412+
require.NoError(t, err)
413+
414+
cands := drainAll(t, iter)
415+
require.Len(t, cands, 2, "the absent dependency is still an open question with two sides")
416+
assert.Zero(t, sc.total, "the scorer is never handed a batch the snapshot did not carry")
417+
assert.InDelta(t, math.Log(defaultProbability), cands[0].RankingScore, 1e-9)
418+
}
419+
420+
// A merging dependency is still in progress — the merge can fail — so it stays
421+
// an open question here like any other. Whether a path betting against it is
422+
// worth funding is a matter of price, which is the scorer's to say, not a
423+
// state the search hard-codes.
424+
func TestBestFirst_MergingDependencyStaysOpen(t *testing.T) {
425+
batches := []entity.Batch{
426+
{ID: "q/landing", State: entity.BatchStateMerging},
427+
{ID: "q/H", State: entity.BatchStateSpeculating, Dependencies: []string{"q/landing"}},
428+
}
429+
sc := newCountingScorer(map[string]float64{"q/landing": 0.9})
430+
431+
iter, err := New(sc).Generate(context.Background(), batches)
432+
require.NoError(t, err)
433+
cands := drainAll(t, iter)
434+
435+
assert.Equal(t, 1, sc.calls["q/landing"], "a merging dependency is priced like any other")
436+
require.Len(t, cands, 2, "both sides of a merge that has not landed yet")
437+
assert.Equal(t, entity.DependencyAssumptionSucceeds, assumptionFor(cands[0].Path, "q/landing"))
438+
assert.Equal(t, entity.DependencyAssumptionFails, assumptionFor(cands[1].Path, "q/landing"))
393439
}
394440

395441
func TestBestFirst_GeneratesOnlyWhatIsPulled(t *testing.T) {
@@ -774,6 +820,28 @@ func TestBestFirst_HonorsCancelledContext(t *testing.T) {
774820
assert.False(t, ok)
775821
assert.Equal(t, entity.CandidatePath{}, c)
776822
})
823+
824+
t.Run("a scorer that fails on a dead context ends the run", func(t *testing.T) {
825+
// The loop checks ctx before each call, so a context that dies during
826+
// the LAST call is the one it cannot catch — and absorbing that as an
827+
// unpriceable dependency would hand an iterator back to a caller that
828+
// has already gone.
829+
ctx, cancel := context.WithCancel(context.Background())
830+
defer cancel()
831+
832+
iter, err := New(cancellingScorer{cancel: cancel}).Generate(ctx, batches)
833+
require.ErrorIs(t, err, context.Canceled)
834+
assert.Nil(t, iter)
835+
})
836+
}
837+
838+
// cancellingScorer kills the context and then fails, the way a scorer whose
839+
// own call was cancelled would.
840+
type cancellingScorer struct{ cancel context.CancelFunc }
841+
842+
func (s cancellingScorer) Score(context.Context, entity.Batch) (float64, error) {
843+
s.cancel()
844+
return 0, context.Canceled
777845
}
778846

779847
func TestBestFirst_DefaultsScoreOutsideUnitInterval(t *testing.T) {

submitqueue/orchestrator/controller/speculate/finalize.go

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -206,18 +206,22 @@ func (c *Controller) commitOutcome(ctx context.Context, snap *snapshot, batch en
206206
return c.applyOutcome(ctx, snap.store, batch, decision, snap.isTrigger(batch.ID))
207207
}
208208

209-
// recordOutcome writes an outcome's terminal state back into the snapshot so
210-
// the rest of the run reasons from it, exactly as it would from an outcome
211-
// recorded before the run started. It is called only once that state is
209+
// recordOutcome writes the state an outcome moved the batch to back into the
210+
// snapshot so the rest of the run reasons from it, exactly as it would from an
211+
// outcome recorded before the run started. It is called only once that state is
212212
// durable — see commitOutcome — because everything concluded about the
213213
// batches stacked on this one is derived from it.
214214
//
215-
// Only a terminal outcome is recorded. Merging is not terminal — a head
216-
// stacked on this one assumed it would *succeed*, and it has not yet — so a
217-
// merge outcome resolves nothing for anybody else.
215+
// Merging is recorded like the terminal outcomes even though it resolves
216+
// nothing for anybody else: a head stacked on this one assumed it would
217+
// *succeed*, and it has not yet, so every terminal-state check still treats it
218+
// as unresolved. Recording it buys something else — the batch stops reading as
219+
// a head open to new work. Head eligibility is read off this map, both by the
220+
// Speculator and by check, so a head left saying Speculating after the run has
221+
// handed it to the merge stage is a head the run will happily fund again.
218222
func (c *Controller) recordOutcome(snap *snapshot, batchID string, decision outcome) {
219-
state, terminal := decision.terminalState()
220-
if !terminal {
223+
state, written := decision.writtenState()
224+
if !written {
221225
return
222226
}
223227
batch := snap.batches[batchID]
@@ -254,7 +258,8 @@ func (c *Controller) applyOutcome(ctx context.Context, store storage.Storage, ba
254258
}
255259

256260
case outcomeFail, outcomeCancel:
257-
state, terminal = decision.terminalState()
261+
state, _ = decision.writtenState()
262+
terminal = state.IsTerminal()
258263
// A batch decided by a cascade is not the one on the message, so no
259264
// retry or dead letter would ever come back to it — give it a recovery
260265
// message of its own before it turns terminal.

submitqueue/orchestrator/controller/speculate/outcome.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,14 @@ const (
3535
outcomeCancel outcome = "cancel"
3636
)
3737

38-
// terminalState returns the batch state an outcome writes, and whether the
39-
// outcome leaves the batch terminal. Merge is the odd one out: it hands the
38+
// writtenState returns the batch state an outcome moves the batch to.
39+
// outcomeWait moves it nowhere, so it reports false. Merge is the odd one out
40+
// among the states it can return: it is not terminal, because it hands the
4041
// batch to the merge stage, which owns the terminal write that follows.
41-
func (v outcome) terminalState() (entity.BatchState, bool) {
42+
func (v outcome) writtenState() (entity.BatchState, bool) {
4243
switch v {
44+
case outcomeMerge:
45+
return entity.BatchStateMerging, true
4346
case outcomeFail:
4447
return entity.BatchStateFailed, true
4548
case outcomeCancel:

submitqueue/orchestrator/controller/speculate/run.go

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -240,17 +240,21 @@ func terminalPathStatus(status entity.BuildStatus) entity.SpeculationPathStatus
240240
// ask hands the snapshot to the queue's Speculator. Its answer is a proposal,
241241
// not an instruction: check decides what is actually enacted.
242242
//
243-
// The two arguments are deliberately different slices of the queue. Only
244-
// speculating heads are offered as action targets, because only they are open
245-
// to new work. Every in-flight path set is handed over, though, whatever
246-
// state its head is in: a path holds its CI slot until its build actually
247-
// stops, so a merging head's superseded siblings and a cancelling head's live
248-
// builds spend the budget just like a speculating head's do. Hiding them
249-
// would let the allocator count occupied slots as free and oversubscribe CI.
243+
// Both arguments carry the whole queue, whatever state each batch is in. The
244+
// Speculator is handed every batch the run read, because a head's dependencies
245+
// are the facts its paths are built from: a dependency that has moved on to
246+
// merging is no longer a candidate for work, but it is still the reason a path
247+
// assumes what it does, and withholding it leaves the Speculator guessing about
248+
// a batch it cannot see. Every in-flight path set is handed over for the same
249+
// reason: a path holds its CI slot until its build actually stops, so a merging
250+
// head's superseded siblings and a cancelling head's live builds spend the
251+
// budget just like a speculating head's do. Hiding either would let the
252+
// allocator count occupied slots as free and oversubscribe CI.
250253
//
251-
// Passing foreign sets cannot widen what gets proposed: a path ID hashes its
252-
// head, and check rejects any proposal aimed at a head that is not
253-
// speculating.
254+
// Passing the full queue cannot widen what gets proposed. A path ID hashes its
255+
// head, and both this function and check read head eligibility off the same
256+
// snapshot — which finalize keeps current, so a head this run has already
257+
// decided no longer reads as open to work.
254258
func (c *Controller) ask(ctx context.Context, queue string, snap snapshot) ([]entity.Speculation, error) {
255259
spec, err := c.speculators.For(speculator.Config{QueueName: queue})
256260
if err != nil {
@@ -265,7 +269,7 @@ func (c *Controller) ask(ctx context.Context, queue string, snap snapshot) ([]en
265269
}
266270
}
267271

268-
proposals, err := spec.Speculate(ctx, snap.speculating, sets)
272+
proposals, err := spec.Speculate(ctx, snap.batchesForSpeculator(), sets)
269273
if err != nil {
270274
metrics.NamedCounter(c.metricsScope, opName, "speculator_errors", 1)
271275
return nil, fmt.Errorf("speculator failed for queue %s: %w", queue, err)

0 commit comments

Comments
 (0)