Commit 99ced59
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.
Investigating what `merging` should mean to the generator turned up a second, unrelated defect in the merge gate. It is fixed here too, in its own section below.
### 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.
**A head could merge on an assumption that had not come true.** `mergeablePath` required every dependency a path assumed would *succeed* to have actually merged, but imposed no wait at all on one it assumed would *fail*. Its reasoning was that "the path is broken the moment that dependency succeeds" — true only if the transition were instantaneous. It is not: a dependency spends time in `merging`, and before that in `speculating`, having neither succeeded nor failed.
So a passed path that assumed a dependency would fail merged its head immediately, while that dependency was still live and might yet land. That head was built *without* the dependency's changes, so both landing puts a combination on the trunk that no build ever validated — the one thing the queue exists to prevent. Verified against the real predicates before fixing: with the dependency `speculating`, `merging`, or `cancelling`, `mergeablePath` returned true and `decide` returned `merge`.
The rule is now symmetric — a path may merge once every dependency it took a position on has finished the way it assumed. `succeeds` needs `Succeeded`; `fails` needs `Failed` or `Cancelled`; `ignored` is not a position and still imposes no wait, so conflict relaxation is unaffected. `allAssumedSucceedingMerged` becomes `allAssumptionsSettled`.
Note this is not the "bypass large diff" early merge the RFC describes — that reads a passed path for *every* combination of the dependencies and is not implemented on the controller side. A single path betting the right way was never a sound approximation of it. The RFC now says so.
## 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
- `TestMergeablePath` — a fails assumption waits out `speculating`, `merging` and `cancelling`, and merges on `Failed` or `Cancelled`; an assumed-succeeding dependency waits out its merge; an ignored one still imposes no wait
- `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 three tests above 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.1 parent a0d6532 commit 99ced59
13 files changed
Lines changed: 330 additions & 68 deletions
File tree
- doc/rfc/submitqueue
- submitqueue
- extension/speculation/generator
- bestfirst
- orchestrator/controller/speculate
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
52 | | - | |
| 52 | + | |
53 | 53 | | |
54 | 54 | | |
55 | 55 | | |
| |||
447 | 447 | | |
448 | 448 | | |
449 | 449 | | |
| 450 | + | |
450 | 451 | | |
451 | 452 | | |
452 | 453 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
54 | 54 | | |
55 | 55 | | |
56 | 56 | | |
57 | | - | |
| 57 | + | |
58 | 58 | | |
59 | 59 | | |
60 | 60 | | |
| |||
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
| 75 | + | |
| 76 | + | |
75 | 77 | | |
76 | 78 | | |
77 | 79 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
| 5 | + | |
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| |||
Lines changed: 3 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
14 | 16 | | |
15 | 17 | | |
Lines changed: 28 additions & 8 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
28 | | - | |
29 | 28 | | |
30 | 29 | | |
31 | 30 | | |
| |||
104 | 103 | | |
105 | 104 | | |
106 | 105 | | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
107 | 110 | | |
108 | 111 | | |
109 | 112 | | |
110 | 113 | | |
111 | 114 | | |
112 | 115 | | |
113 | | - | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
114 | 125 | | |
115 | | - | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
116 | 135 | | |
117 | 136 | | |
118 | 137 | | |
119 | 138 | | |
120 | 139 | | |
121 | 140 | | |
122 | | - | |
123 | | - | |
124 | | - | |
125 | | - | |
126 | | - | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
127 | 147 | | |
128 | 148 | | |
129 | 149 | | |
| |||
Lines changed: 71 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
381 | 381 | | |
382 | 382 | | |
383 | 383 | | |
384 | | - | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
385 | 388 | | |
386 | 389 | | |
387 | 390 | | |
388 | 391 | | |
389 | 392 | | |
390 | 393 | | |
391 | | - | |
392 | | - | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
393 | 439 | | |
394 | 440 | | |
395 | 441 | | |
| |||
774 | 820 | | |
775 | 821 | | |
776 | 822 | | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
777 | 845 | | |
778 | 846 | | |
779 | 847 | | |
| |||
0 commit comments