Skip to content

Commit bcea46e

Browse files
authored
fix(speculate): write the merge state before dispatching the batch
## Summary ### Why? `applyOutcome` published a batch to the merge topic before writing `BatchStateMerging`, so a lost compare-and-swap could leave Runway acting on an outcome that was never recorded. That ordering existed to avoid a stall, and the stall is real: nothing re-drives a batch stuck in `Merging`. `Process` self-heals only terminal and `Created` batches, `finalize` walks only heads that are still speculating, and the sole production reader of `BatchStateMerging` is the cancel controller — so a batch written `Merging` whose dispatch never went out would sit there forever. Giving that stall a repair path lets the write come first, which is the ordering the rest of the state machine already wants. ### What? `applyOutcome` is restructured into decide-state → recover → write → dispatch. The `terminal` bool falls out: a second switch mirrors the first and dispatches merge or conclude once the state write has landed. `recoverable` is hoisted above the switch so a cascade-decided *merge* gets a recovery message too, not just a cascade-decided failure. It needs one for the same reason: the write drops the batch out of the speculating set, and `Process`'s self-heal only ever names the trigger batch. `Process` gains a `BatchStateMerging` branch that re-sends the dispatch through the new `dispatchMerge` helper. That keeps the stable `IntentID`, the inverse of `fanout`'s `UniqueID` — for conclude a stable ID would suppress the repair, for merge it is what stops Runway merging the batch twice. One side benefit: a lost state CAS now means the dispatch is never sent at all, narrowing the window where a cancelled batch has a live merge request against it. ## Test Plan ✅ `bazel test //submitqueue/... //platform/...` — 68 tests pass New coverage: the dispatch follows the state write; a lost CAS publishes nothing; a cascade-merged batch gets its recovery signal before the write; `Process` on a `Merging` batch re-dispatches; `dispatchMerge` reuses one message ID per batch. `TestProcess_MergingRunsButDoesNotAct` asserted the old behaviour — that a `Merging` batch publishes nothing — and is replaced by `TestProcess_MergingSelfHeals`.
1 parent 0c10b50 commit bcea46e

4 files changed

Lines changed: 171 additions & 44 deletions

File tree

submitqueue/orchestrator/controller/speculate/finalize.go

Lines changed: 37 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -302,52 +302,32 @@ func (c *Controller) recordOutcome(snap *snapshot, batchID string, decision outc
302302
// applyOutcome enacts a decided outcome on a batch, reporting whether the
303303
// state write landed.
304304
//
305-
// The publish order differs per arm, but it is one rule read twice: a publish
306-
// may precede a state write only when the consumer does not read the state
307-
// that write produces. The merge stage correlates on the batch ID alone, so
308-
// telling it before the write is safe — a batch recorded Merging that Runway
309-
// never heard about would merely stall. Conclude does read the state (it
310-
// reconciles requests from it and rejects a non-terminal batch outright), so
311-
// it is published only after the write, or it would race the consumer into
312-
// the dead-letter queue.
313-
//
314-
// Losing the state compare-and-swap is not an error: another writer got
315-
// there, and the next run reads whatever they wrote. It is reported as not
316-
// landed, because the outcome this run reached is not the one that took
317-
// effect.
305+
// Nothing is dispatched until the state it describes is durable, so no
306+
// consumer can act on an outcome a lost compare-and-swap refused to write. The
307+
// cost is a dispatch that fails on a batch finalize no longer walks, which the
308+
// recovery message and Process's self-heal exist to repair.
318309
func (c *Controller) applyOutcome(ctx context.Context, store storage.Storage, batch entity.Batch, decision outcome, isTriggerBatch bool) (bool, error) {
319310
var state entity.BatchState
320-
terminal := false
321311

322312
switch decision {
323313
case outcomeMerge:
324314
state = entity.BatchStateMerging
325-
// A batch merges once, so the dispatch names only that as its cause: a
326-
// redelivery that re-derives outcomeMerge because the state write was
327-
// lost dedups against the request already sent, instead of asking
328-
// Runway to merge the same batch twice.
329-
if err := c.publishBatchID(ctx, topickey.TopicKeyMerge, publish.IntentID(batch.ID, "merge-dispatch"), batch.ID, batch.Queue, batch.Queue); err != nil {
330-
metrics.NamedCounter(c.metricsScope, opName, "publish_errors", 1)
331-
return false, fmt.Errorf("failed to publish batch %s to merge: %w", batch.ID, err)
332-
}
333315

334316
case outcomeFail, outcomeCancel:
335-
state, terminal = decision.terminalState()
336-
// A batch decided by a cascade is not the one on the message, so no
337-
// retry or dead letter would ever come back to it — give it a recovery
338-
// message of its own before it turns terminal.
339-
if !isTriggerBatch {
340-
if err := c.recoverable(ctx, store, batch); err != nil {
341-
return false, err
342-
}
343-
}
317+
state, _ = decision.terminalState()
344318

345319
default:
346320
// outcomeWait: nothing to enact. Listed explicitly so an unknown or
347321
// zero outcome can never fall into an enacting arm.
348322
return false, nil
349323
}
350324

325+
if !isTriggerBatch {
326+
if err := c.recoverable(ctx, store, batch); err != nil {
327+
return false, err
328+
}
329+
}
330+
351331
// Through Transition, so the queue's membership record moves with the
352332
// state. A raw CAS would leave the batch filed under the bucket it just
353333
// left, and since records are only ever added, every later run of this
@@ -369,7 +349,13 @@ func (c *Controller) applyOutcome(ctx context.Context, store storage.Storage, ba
369349
"state", string(state),
370350
)
371351

372-
if terminal {
352+
switch decision {
353+
case outcomeMerge:
354+
if err := c.dispatchMerge(ctx, batch); err != nil {
355+
return true, err
356+
}
357+
358+
case outcomeFail, outcomeCancel:
373359
// Named for the run that decided it, so a redelivery re-deriving the
374360
// same outcome does not conclude the batch twice, and so it stays
375361
// distinct from the conclude mergesignal sends for a merged batch.
@@ -383,15 +369,26 @@ func (c *Controller) applyOutcome(ctx context.Context, store storage.Storage, ba
383369
return true, nil
384370
}
385371

386-
// recoverable gives a batch a message of its own before this run makes it
387-
// terminal, so its fan-out cannot be stranded by a failure afterwards.
372+
// dispatchMerge hands a batch to the merge stage under a stable ID, so both a
373+
// redelivery and the Merging self-heal dedupe against the request already sent
374+
// rather than asking Runway to merge the batch twice.
375+
func (c *Controller) dispatchMerge(ctx context.Context, batch entity.Batch) error {
376+
if err := c.publishBatchID(ctx, topickey.TopicKeyMerge, publish.IntentID(batch.ID, "merge-dispatch"), batch.ID, batch.Queue, batch.Queue); err != nil {
377+
metrics.NamedCounter(c.metricsScope, opName, "publish_errors", 1)
378+
return fmt.Errorf("failed to publish batch %s to merge: %w", batch.ID, err)
379+
}
380+
return nil
381+
}
382+
383+
// recoverable gives a batch a message of its own before this run moves it out
384+
// of the speculating set, so what has to follow the write cannot be stranded
385+
// by a failure afterwards.
388386
//
389-
// Every other terminal batch is repaired through the message that names it: a
390-
// redelivery finds it terminal and re-publishes from Process's self-heal
391-
// branch, and a persistent failure lands it in the dead-letter queue by name.
392-
// A batch decided by a cascade has neither — it is not the batch on the
393-
// message, and once terminal it is gone from the queue listing — so without
394-
// this its requests would simply stay unreconciled.
387+
// A batch named by a message is repaired through it: a redelivery re-publishes
388+
// from one of Process's self-heal branches, and a persistent failure
389+
// dead-letters by name. A cascade-decided batch has neither, and finalize only
390+
// walks heads still speculating — so a merged one would never reach Runway,
391+
// and a terminal one would leave its requests unreconciled.
395392
//
396393
// Distinct per publish: the guarantee being bought is that a message exists at
397394
// all, and a stable ID would let the queue answer "one already did" with a

submitqueue/orchestrator/controller/speculate/run_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,60 @@ func TestRun_MergeableHeadGainsNoNewPath(t *testing.T) {
700700
assert.Zero(t, spec.calls)
701701
}
702702

703+
// The dispatch is what takes a batch out of this stage's hands, so sending it
704+
// before the write would let Runway act on an outcome a lost compare-and-swap
705+
// refused to record.
706+
func TestRun_MergeableHeadDispatchesAfterTheStateWrite(t *testing.T) {
707+
ctrl := gomock.NewController(t)
708+
passed := pathOver(entity.DependencyAssumptionSucceeds, entity.DependencyAssumptionSucceeds)
709+
710+
h := newRunHarness(t, ctrl, &scriptedSpeculator{}, []entity.Batch{speculatingHead()})
711+
h.noBuildsDispatched()
712+
h.batches.EXPECT().Get(gomock.Any(), dep1).Return(entity.Batch{ID: dep1, State: entity.BatchStateSucceeded}, nil)
713+
h.batches.EXPECT().Get(gomock.Any(), dep2).Return(entity.Batch{ID: dep2, State: entity.BatchStateSucceeded}, nil)
714+
h.pathSets.EXPECT().Get(gomock.Any(), head).Return(entity.SpeculationPathSet{
715+
Head: head,
716+
Paths: []entity.SpeculationPathEntry{entryFor(passed, entity.SpeculationPathStatusPassed)},
717+
Version: 1,
718+
}, nil).AnyTimes()
719+
720+
var publishedBeforeWrite []string
721+
h.batches.EXPECT().
722+
Update(gomock.Any(), updateTo{id: head, state: entity.BatchStateMerging}, int32(1), int32(2)).
723+
DoAndReturn(func(context.Context, entity.Batch, int32, int32) error {
724+
publishedBeforeWrite = append([]string(nil), h.published...)
725+
return nil
726+
})
727+
728+
require.NoError(t, h.run(head))
729+
assert.Equal(t, []string{"submitqueue-merge"}, h.published)
730+
assert.Empty(t, publishedBeforeWrite,
731+
"nothing may reach the merge stage before the state it acts on is written")
732+
}
733+
734+
// The other half: a lost write means another writer owns the batch, so the
735+
// dispatch it would have justified is never sent.
736+
func TestRun_MergeableHeadDispatchesNothingWhenTheStateCASLoses(t *testing.T) {
737+
ctrl := gomock.NewController(t)
738+
passed := pathOver(entity.DependencyAssumptionSucceeds, entity.DependencyAssumptionSucceeds)
739+
740+
h := newRunHarness(t, ctrl, &scriptedSpeculator{}, []entity.Batch{speculatingHead()})
741+
h.noBuildsDispatched()
742+
h.batches.EXPECT().Get(gomock.Any(), dep1).Return(entity.Batch{ID: dep1, State: entity.BatchStateSucceeded}, nil)
743+
h.batches.EXPECT().Get(gomock.Any(), dep2).Return(entity.Batch{ID: dep2, State: entity.BatchStateSucceeded}, nil)
744+
h.pathSets.EXPECT().Get(gomock.Any(), head).Return(entity.SpeculationPathSet{
745+
Head: head,
746+
Paths: []entity.SpeculationPathEntry{entryFor(passed, entity.SpeculationPathStatusPassed)},
747+
Version: 1,
748+
}, nil).AnyTimes()
749+
h.batches.EXPECT().
750+
Update(gomock.Any(), updateTo{id: head, state: entity.BatchStateMerging}, int32(1), int32(2)).
751+
Return(storage.ErrVersionMismatch)
752+
753+
require.NoError(t, h.run(head))
754+
assert.Empty(t, h.published)
755+
}
756+
703757
// A head with no future left fails, and the write order is what keeps conclude
704758
// usable: conclude reconciles requests from the batch's state and rejects a
705759
// non-terminal one, so it is published only once the terminal write has landed.
@@ -1049,6 +1103,40 @@ func TestFanout_MintsADistinctMessageIDPerPublish(t *testing.T) {
10491103
assert.NotEqual(t, ids[0], ids[1])
10501104
}
10511105

1106+
// The merge dispatch is the mirror image: a batch merges once, so the ID has
1107+
// to be stable across the redelivery and the self-heal that both re-derive it.
1108+
func TestDispatchMerge_ReusesOneMessageIDPerBatch(t *testing.T) {
1109+
ctrl := gomock.NewController(t)
1110+
1111+
var ids []string
1112+
pub := queuemock.NewMockPublisher(ctrl)
1113+
pub.EXPECT().Publish(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
1114+
func(_ context.Context, _ string, msg entityqueue.Message) error {
1115+
ids = append(ids, msg.ID)
1116+
return nil
1117+
},
1118+
).Times(2)
1119+
q := queuemock.NewMockQueue(ctrl)
1120+
q.EXPECT().Publisher().Return(pub).AnyTimes()
1121+
1122+
registry, err := consumer.NewTopicRegistry([]consumer.TopicConfig{
1123+
{Key: topickey.TopicKeyMerge, Name: "submitqueue-merge", Queue: q},
1124+
})
1125+
require.NoError(t, err)
1126+
1127+
c := NewController(
1128+
zaptest.NewLogger(t).Sugar(), tally.NoopScope, staticStorageFactory{store: storagemock.NewMockStorage(ctrl)},
1129+
staticSpeculatorFactory{}, registry, topickey.TopicKeySpeculate, "orchestrator-speculate",
1130+
)
1131+
batch := entity.Batch{ID: head, Queue: "q"}
1132+
1133+
require.NoError(t, c.dispatchMerge(context.Background(), batch))
1134+
require.NoError(t, c.dispatchMerge(context.Background(), batch))
1135+
1136+
require.Len(t, ids, 2)
1137+
assert.Equal(t, ids[0], ids[1])
1138+
}
1139+
10521140
// cascadePair wires a queue where `prerequisite` reaches a terminal outcome
10531141
// and `derived` fails only because of it: derived bet that prerequisite would
10541142
// not succeed, built on that, and its build failed. Until prerequisite is terminal the
@@ -1248,6 +1336,38 @@ func TestRun_TriggerBatchNeedsNoRecoverySignal(t *testing.T) {
12481336
assert.Equal(t, []string{"conclude"}, h.published)
12491337
}
12501338

1339+
// Merging needs the same signal for the same reason: the write drops the batch
1340+
// out of the speculating set, so nothing else would ever dispatch it.
1341+
func TestRun_CascadeDerivedBatchIsGivenARecoverySignalBeforeItMerges(t *testing.T) {
1342+
ctrl := gomock.NewController(t)
1343+
1344+
merging := entity.Batch{ID: "q/batch/derived", Queue: "q", State: entity.BatchStateSpeculating, Version: 1}
1345+
h := newRunHarness(t, ctrl, &scriptedSpeculator{}, []entity.Batch{merging})
1346+
h.noBuildsDispatched()
1347+
1348+
// No dependencies, so the passed path has nothing left to settle.
1349+
passedPath := entity.SpeculationPath{Head: merging.ID}
1350+
h.pathSets.EXPECT().Get(gomock.Any(), merging.ID).Return(entity.SpeculationPathSet{
1351+
Head: merging.ID,
1352+
Paths: []entity.SpeculationPathEntry{entryFor(passedPath, entity.SpeculationPathStatusPassed)},
1353+
Version: 1,
1354+
}, nil).AnyTimes()
1355+
1356+
var publishedBeforeWrite []string
1357+
h.batches.EXPECT().
1358+
Update(gomock.Any(), updateTo{id: merging.ID, state: entity.BatchStateMerging}, int32(1), int32(2)).
1359+
DoAndReturn(func(context.Context, entity.Batch, int32, int32) error {
1360+
publishedBeforeWrite = append([]string(nil), h.published...)
1361+
return nil
1362+
})
1363+
1364+
// The message names some other batch, so a retry would never come back here.
1365+
require.NoError(t, h.run(head))
1366+
1367+
assert.Equal(t, []string{"speculate"}, publishedBeforeWrite)
1368+
assert.Equal(t, []string{"speculate", "submitqueue-merge"}, h.published)
1369+
}
1370+
12511371
// A cancelling path whose build is still running finishes only when CI actually
12521372
// stops. The run writes nothing (the intent is already recorded), publishes
12531373
// nothing (the poll loop is what keeps asking the runner to stop), and the

submitqueue/orchestrator/controller/speculate/speculate.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,15 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
137137
}
138138
}
139139

140+
// A Merging batch has left the set finalize walks, so a message naming it
141+
// is the only thing that will look at it again.
142+
if batch.State == entity.BatchStateMerging {
143+
metrics.NamedCounter(c.metricsScope, opName, "self_heal_merging", 1)
144+
if err := c.dispatchMerge(ctx, batch); err != nil {
145+
return c.attributed(err, entity.BatchSubject(batch.ID))
146+
}
147+
}
148+
140149
return c.run(ctx, store, batch)
141150
}
142151

submitqueue/orchestrator/controller/speculate/speculate_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,9 +276,10 @@ func TestProcess_TerminalReplansQueue(t *testing.T) {
276276
"the dependent must be re-planned against the terminal outcome, which it can only be weighed against if the terminal batch comes too")
277277
}
278278

279-
// A Merging batch is the merge stage's to finish; the run still happens for the
280-
// rest of the queue, but this batch is not an action target.
281-
func TestProcess_MergingRunsButDoesNotAct(t *testing.T) {
279+
// A Merging batch has left the speculating set, so a message naming it is the
280+
// only thing that will look at it again: it re-sends the dispatch to repair
281+
// one lost after the state write.
282+
func TestProcess_MergingSelfHeals(t *testing.T) {
282283
ctrl := gomock.NewController(t)
283284
h := newProcHarness(t, ctrl, nil)
284285
batch := testBatch(entity.BatchStateMerging)
@@ -287,7 +288,7 @@ func TestProcess_MergingRunsButDoesNotAct(t *testing.T) {
287288
h.listsInFlight()
288289

289290
require.NoError(t, h.process(t, ctrl, batch.ID))
290-
assert.Empty(t, h.published)
291+
assert.Equal(t, []string{"submitqueue-merge"}, h.published)
291292
}
292293

293294
func TestProcess_Errors(t *testing.T) {

0 commit comments

Comments
 (0)