Skip to content

Commit 96e0660

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.** `ask` now flattens `snap.batches` — every batch the run read — and passes that. No ordering is imposed: the generator's heap comparator is a strict total order, so its candidate sequence is identical whatever order the input arrives in (checked over 200 shuffles), and a Speculator that read meaning into input order would be relying on something the contract never offered. `ask`'s doc comment argued for the narrow slice and is rewritten. Widening is safe because of the commit below this one. A head this run has just decided still reads as `Speculating` in the snapshot — `finalize` records only terminal outcomes — and both the Speculator and `check` read head eligibility off that same map, so a stale entry would fool both filters at once. What stops it mattering is that a head now merges only once *every* dependency has settled: the generator pins them all, can therefore construct nothing but the path that already passed, and the allocator skips that as finished. Verified by driving the real `bestfirst`/`sticky` pair with a merged head and settled dependencies — zero actions proposed for it. That ordering is load-bearing, which is why the merge gate is the parent rather than a follow-up. With the old gate a head could merge past a dependency that was still live; the generator would then see that dependency as an open question, offer a path ID the set had never held, and the allocator would fund a fresh build for a batch already handed to Runway. **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 - `TestBestFirst_HonorsCancelledContext/a scorer that fails on a dead context ends the run` The last was added for a defect a review of this branch turned up, and was confirmed to fail against the code as it stood before the 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. The merge-gate defect found while investigating this one — a head could merge on a *fails* assumption that had not come true — is the parent commit, since the widening here relies on the invariant it restores.
1 parent d35e3e7 commit 96e0660

8 files changed

Lines changed: 131 additions & 35 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/run.go

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"context"
1919
"errors"
2020
"fmt"
21+
"maps"
22+
"slices"
2123

2224
"github.com/uber/submitqueue/platform/metrics"
2325
corebatch "github.com/uber/submitqueue/submitqueue/core/batch"
@@ -240,17 +242,20 @@ func terminalPathStatus(status entity.BuildStatus) entity.SpeculationPathStatus
240242
// ask hands the snapshot to the queue's Speculator. Its answer is a proposal,
241243
// not an instruction: check decides what is actually enacted.
242244
//
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.
245+
// Both arguments carry the whole queue, whatever state each batch is in. A
246+
// head's dependencies are the facts its paths are built from, so a dependency
247+
// withheld is one the Speculator has to plan around blind. Every in-flight
248+
// path set goes over for the same reason: a path holds its CI slot until its
249+
// build actually stops, so a merging head's superseded siblings and a
250+
// cancelling head's live builds spend the budget just like a speculating
251+
// head's do. Hiding either would let the allocator count occupied slots as
252+
// free and oversubscribe CI.
250253
//
251-
// Passing foreign sets cannot widen what gets proposed: a path ID hashes its
254+
// Passing the full queue cannot widen what gets proposed: a path ID hashes its
252255
// head, and check rejects any proposal aimed at a head that is not
253-
// speculating.
256+
// speculating. A head this run has just decided still reads as Speculating
257+
// here, but it can only rebuild the path that already passed — see
258+
// mergeablePath — which the allocator skips as finished.
254259
func (c *Controller) ask(ctx context.Context, queue string, snap snapshot) ([]entity.Speculation, error) {
255260
spec, err := c.speculators.For(speculator.Config{QueueName: queue})
256261
if err != nil {
@@ -265,7 +270,7 @@ func (c *Controller) ask(ctx context.Context, queue string, snap snapshot) ([]en
265270
}
266271
}
267272

268-
proposals, err := spec.Speculate(ctx, snap.speculating, sets)
273+
proposals, err := spec.Speculate(ctx, slices.Collect(maps.Values(snap.batches)), sets)
269274
if err != nil {
270275
metrics.NamedCounter(c.metricsScope, opName, "speculator_errors", 1)
271276
return nil, fmt.Errorf("speculator failed for queue %s: %w", queue, err)

submitqueue/orchestrator/controller/speculate/run_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func (h *runHarness) failPublishTo(topic string) {
9494
h.failTopic = topic
9595
}
9696

97-
// speculatedOver returns the IDs of the heads the Speculator was offered.
97+
// speculatedOver returns the IDs of the batches the Speculator was offered.
9898
func (h *runHarness) speculatedOver() []string {
9999
ids := make([]string, 0, len(h.spec.gotBatches))
100100
for _, b := range h.spec.gotBatches {
@@ -280,8 +280,8 @@ func TestRun_PassesSnapshotToSpeculator(t *testing.T) {
280280
require.NoError(t, h.run(head))
281281

282282
require.Equal(t, 1, spec.calls)
283-
require.Len(t, spec.gotBatches, 1)
284-
assert.Equal(t, head, spec.gotBatches[0].ID, "only speculating heads are action targets")
283+
assert.ElementsMatch(t, []string{dep1, dep2, head, merging.ID}, h.speculatedOver(),
284+
"every batch the run read, in no particular order")
285285
require.Len(t, spec.gotSets, 1)
286286
assert.Equal(t, int32(3), spec.gotSets[0].Version)
287287
}
@@ -962,8 +962,8 @@ func TestRun_SpeculatorSeesPathSetsOfNonOpenHeads(t *testing.T) {
962962

963963
require.NoError(t, h.run(head))
964964

965-
assert.Equal(t, []entity.Batch{open}, spec.gotBatches,
966-
"only an open head may be an action target")
965+
assert.ElementsMatch(t, []entity.Batch{open, merging}, spec.gotBatches,
966+
"a closed head is still a fact the open ones are planned against")
967967
require.Len(t, spec.gotSets, 2, "every in-flight path set counts against the budget")
968968
assert.Equal(t, merging.ID, spec.gotSets[1].Head)
969969
}

submitqueue/orchestrator/controller/speculate/speculate_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,14 @@ import (
3535
)
3636

3737
// quietSpeculator proposes nothing, which is what tests of the message-level
38-
// branches want: the run happens but changes no paths. It records the heads it
39-
// was offered, so a test can assert that a run reached them at all.
38+
// branches want: the run happens but changes no paths. It records the batches
39+
// it was offered, so a test can assert that a run reached them at all.
4040
type quietSpeculator struct {
41-
heads []entity.Batch
41+
saw []entity.Batch
4242
}
4343

4444
func (s *quietSpeculator) Speculate(_ context.Context, batches []entity.Batch, _ []entity.SpeculationPathSet) ([]entity.Speculation, error) {
45-
s.heads = append(s.heads, batches...)
45+
s.saw = append(s.saw, batches...)
4646
return nil, nil
4747
}
4848

@@ -239,8 +239,8 @@ func TestProcess_TerminalReplansQueue(t *testing.T) {
239239
Return(entity.SpeculationPathSet{}, storage.ErrNotFound)
240240

241241
require.NoError(t, h.process(t, ctrl, batch.ID))
242-
assert.Equal(t, []entity.Batch{dependent}, h.spec.heads,
243-
"the dependent must be re-planned against the terminal outcome")
242+
assert.ElementsMatch(t, []entity.Batch{batch, dependent}, h.spec.saw,
243+
"the dependent must be re-planned against the terminal outcome, which it can only be weighed against if the terminal batch comes too")
244244
}
245245

246246
// A Merging batch is the merge stage's to finish; the run still happens for the

0 commit comments

Comments
 (0)