Skip to content

Commit 5732f0f

Browse files
committed
feat(speculate): hold speculating until the batch can be sent to merge
## Summary ### Why? A request's trail read `batched → speculating → speculated → speculating → speculated → landing → landed`, and the repeats looked like the pipeline regressing. They were not a reporting glitch: `RequestStatusSpeculated` meant "a build passed on a path still consistent with how its dependencies are resolving", so it was published while the batch was still blocked, and `reportSpeculation` republished `speculating` whenever a dependency later resolved against that path's guess. Each extra pair was one speculative guess that passed and was then invalidated. That made `speculated` a per-path, provisional fact wearing a status — the exact shape `RequestEvent` exists for. A batch is not done speculating until it can be sent to merge; waiting on dependencies is still speculating. ### What? Two events join the vocabulary. `waiting` records that a path passed and the batch has nothing of its own left to run; `invalidated` records that a dependency resolved against the guess that path made. Both are occurrence-keyed on the path ID, so a passed path re-observed across runs collapses to one entry. `waiting` is gated on `outcomeWait` rather than on merely holding a live passed path. A merge is decided on that same predicate — `mergeablePath` implies `livePassedPath` — so an ungated report would claim a wait on every request that merges straight through. `reportSpeculation` moves below `decide` to see the outcome; both it and `decide` only read, so the reorder observes nothing different. `speculated` stays a status but now means speculation finished, published from `dispatchMerge` once the batch is cleared to merge. It goes ahead of the dispatch because the merge stage publishes `landing` as its first act on receiving one, and both statuses are non-terminal — so a `speculated` sent afterwards could carry the later timestamp and beat `landing` in the summary. The `hadPassed && !hasPassed` republish of `speculating` is gone. The status never leaves, so there is nothing to republish, and the oscillation goes with it. One trade-off worth naming: `speculated` is now near-instantaneous, so "is this batch blocked on dependencies?" is answerable from the latest event rather than from the status. ## Test Plan ✅ `bazel test //submitqueue/... //platform/...` — 68 tests pass The two `reportSpeculation` tests now assert events. New coverage: a merging head reports `speculated` and no wait — the gate's regression test — and `speculated` is published before the merge dispatch. `test/e2e/submitqueue/suite_test.go` needs no change: `speculating → speculated → landing → landed` still holds as an ordered subsequence, now for a different reason and at a different point in time. ## Issue Closes https://linear.app/uber/issue/CODEM-443
1 parent 42d1cb7 commit 5732f0f

3 files changed

Lines changed: 117 additions & 57 deletions

File tree

submitqueue/entity/request_log.go

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,12 @@ const (
5454
// RequestStatusBatched indicates that the request has been included in a new batch and will be sent to speculation.
5555
RequestStatusBatched RequestStatus = "batched"
5656

57-
// RequestStatusSpeculating indicates that the batch containing the request has been admitted to speculation: candidate paths are being planned and built.
57+
// RequestStatusSpeculating indicates that the batch containing the request is in speculation:
58+
// planning, building, or waiting for its dependencies to settle. None of those leaves it able to land.
5859
RequestStatusSpeculating RequestStatus = "speculating"
5960

60-
// RequestStatusSpeculated indicates that the batch containing the request has a build that passed on a path still
61-
// consistent with how its dependencies are resolving, and is waiting for those dependencies to settle before it can land.
61+
// RequestStatusSpeculated indicates that the batch containing the request has finished speculating:
62+
// a build passed on a path whose assumptions all held, and the batch has been cleared to merge.
6263
RequestStatusSpeculated RequestStatus = "speculated"
6364

6465
// RequestStatusLanding indicates that the request is actively being landed (e.g., source control operation is in progress to push the change to the target branch).
@@ -84,17 +85,17 @@ const (
8485
// RequestEvent is something that happened to a request while it sat at a status,
8586
// rather than a status of its own.
8687
//
87-
// Build progress is what the distinction exists for. A batch funds several
88-
// speculation paths at once and each is built separately, so a build starting or
89-
// finishing says nothing about where the request as a whole is — it is still
90-
// speculating. Were these statuses, one build succeeding while its siblings ran
91-
// would report the request as finished, and go on reporting it that way until the
92-
// batch resolved, because nothing else publishes in between.
88+
// Speculation is what the distinction exists for. A batch funds several paths at
89+
// once and each is built separately, so a build starting or finishing, or one
90+
// path passing and later being contradicted, says nothing about where the request
91+
// as a whole is — it is still speculating. Were these statuses, one build
92+
// succeeding while its siblings ran would report the request as finished, and go
93+
// on reporting it that way until the batch resolved.
9394
//
94-
// Events are not unique per request: each names one build, and a batch may be
95-
// built many times as speculation re-plans. They belong in a request's history
96-
// and are never its current status — which is enforced by the type, since a
97-
// RequestEvent cannot be assigned to RequestSummary.Status.
95+
// Events are not unique per request: each names one path or build, and a batch
96+
// may be re-planned many times. They belong in a request's history and are never
97+
// its current status — which is enforced by the type, since a RequestEvent cannot
98+
// be assigned to RequestSummary.Status.
9899
type RequestEvent string
99100

100101
const (
@@ -107,6 +108,14 @@ const (
107108
// RequestEventBuilt indicates that one build verifying one speculation path of the batch containing the request finished successfully.
108109
// A build that fails or is cancelled records nothing.
109110
RequestEventBuilt RequestEvent = "built"
111+
112+
// RequestEventWaiting indicates that one speculation path of the batch containing the request passed,
113+
// leaving the batch nothing of its own to run and waiting on its dependencies to settle.
114+
RequestEventWaiting RequestEvent = "waiting"
115+
116+
// RequestEventInvalidated indicates that a dependency resolved against the guess made by the passed path
117+
// the batch containing the request was waiting on, so that path can no longer carry it.
118+
RequestEventInvalidated RequestEvent = "invalidated"
110119
)
111120

112121
// RequestLogType is what a log entry records: the request reaching a status, or

submitqueue/orchestrator/controller/speculate/finalize.go

Lines changed: 33 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,12 @@ func (c *Controller) finalize(ctx context.Context, snap *snapshot) error {
8383
snap.markDirty(batch.ID)
8484
}
8585

86-
if err := c.reportSpeculation(ctx, batch, set, *snap, before, hadPassed); err != nil {
86+
decision := decide(batch, set, *snap)
87+
88+
if err := c.reportSpeculation(ctx, batch, set, *snap, before, hadPassed, decision); err != nil {
8789
return err
8890
}
8991

90-
decision := decide(batch, set, *snap)
9192
if decision == outcomeWait {
9293
stillOpen = append(stillOpen, batch)
9394
continue
@@ -129,52 +130,41 @@ func (c *Controller) finalize(ctx context.Context, snap *snapshot) error {
129130
return nil
130131
}
131132

132-
// reportSpeculation tells a head's members how far speculation has got.
133+
// reportSpeculation records what the fold above did to a head's passed path.
134+
// Both facts are per-path and the head stays BatchStateSpeculating throughout,
135+
// so neither is a status and the request log is the only place they show up.
133136
//
134-
// Two moments are worth reporting and neither is a batch state — a head is
135-
// BatchStateSpeculating from admission until its outcome, so the request log is
136-
// the only place either becomes visible:
137+
// before comes from passedEntry, not livePassedPath: that predicate and the
138+
// fold both exclude a contradicted path, so two livePassedPath calls could
139+
// never see the loss. Only the run that does the breaking sees it at all.
137140
//
138-
// - the head has a live passed path. Its own work is done and what remains is
139-
// other batches finishing, a wait that can run for minutes and reads very
140-
// differently to still building.
141-
// - it just lost the one it had, because a dependency resolved against that
142-
// path's guess. The head is back to building, and without this its members
143-
// would go on reading as speculated through the whole rebuild.
144-
//
145-
// The second is why before is taken from passedEntry rather than livePassedPath:
146-
// both that predicate and the fold above exclude a contradicted path, so a pair
147-
// of livePassedPath calls could never see the loss happen. What is compared is
148-
// "held a passed build" before the fold against "still has one worth waiting on"
149-
// after it, and only the run that does the breaking sees the difference — every
150-
// later run finds the entry already cancelled.
151-
//
152-
// Both facts are derived from the snapshot rather than stored, so this runs on
153-
// every pass over an open head and relies on the occurrence to collapse the
154-
// repeats: a path ID hashes its head along with its assumptions, so it names the
155-
// batch too, and one passed path re-observed by a hundred runs is a single entry
156-
// while a different path winning after a re-plan is correctly a new one.
141+
// Nothing is stored, so this runs on every pass and leans on the occurrence to
142+
// collapse repeats — a path ID hashes its head with its assumptions, so one
143+
// passed path re-observed stays one entry while a re-plan's winner is a new one.
157144
func (c *Controller) reportSpeculation(
158145
ctx context.Context,
159146
batch entity.Batch,
160147
set entity.SpeculationPathSet,
161148
snap snapshot,
162149
before entity.SpeculationPathEntry,
163150
hadPassed bool,
151+
decision outcome,
164152
) error {
165153
after, hasPassed := livePassedPath(set, snap)
166154

167-
status, path := entity.RequestStatusSpeculated, after
155+
// A merge is decided on the same live passed path, so an ungated report
156+
// would claim a wait on every head that merges straight through.
157+
event, path := entity.RequestEventWaiting, after
168158
switch {
169-
case hasPassed:
170-
case hadPassed:
171-
status, path = entity.RequestStatusSpeculating, before
159+
case hasPassed && decision == outcomeWait:
160+
case hadPassed && !hasPassed:
161+
event, path = entity.RequestEventInvalidated, before
172162
default:
173163
return nil
174164
}
175165

176-
if err := corerequest.PublishBatchLogs(ctx, c.registry, batch.Queue, batch.Contains,
177-
status, path.ID, map[string]string{
166+
if err := corerequest.PublishBatchEvents(ctx, c.registry, batch.Queue, batch.Contains,
167+
event, path.ID, map[string]string{
178168
"batch_id": batch.ID,
179169
"path_id": path.ID,
180170
},
@@ -183,7 +173,7 @@ func (c *Controller) reportSpeculation(
183173
// Attributed to this head, not the trigger: the loop walks the whole
184174
// queue, so the batch whose members could not be told is usually not the
185175
// one the message named.
186-
return c.attributed(fmt.Errorf("failed to publish request logs for batch %s: %w", batch.ID, err),
176+
return c.attributed(fmt.Errorf("failed to publish request events for batch %s: %w", batch.ID, err),
187177
entity.BatchSubject(batch.ID))
188178
}
189179
return nil
@@ -369,10 +359,18 @@ func (c *Controller) applyOutcome(ctx context.Context, store storage.Storage, ba
369359
return true, nil
370360
}
371361

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.
362+
// dispatchMerge reports speculation finished and hands the batch to the merge
363+
// stage. The stable ID means a redelivery or the Merging self-heal dedupes
364+
// against the request already sent instead of merging twice; the status goes
365+
// first so it cannot be timestamped after the landing the dispatch triggers.
375366
func (c *Controller) dispatchMerge(ctx context.Context, batch entity.Batch) error {
367+
if err := corerequest.PublishBatchLogs(ctx, c.registry, batch.Queue, batch.Contains,
368+
entity.RequestStatusSpeculated, batch.ID, map[string]string{"batch_id": batch.ID},
369+
); err != nil {
370+
metrics.NamedCounter(c.metricsScope, opName, "request_log_errors", 1)
371+
return fmt.Errorf("failed to publish request logs for batch %s: %w", batch.ID, err)
372+
}
373+
376374
if err := c.publishBatchID(ctx, topickey.TopicKeyMerge, publish.IntentID(batch.ID, "merge-dispatch"), batch.ID, batch.Queue, batch.Queue); err != nil {
377375
metrics.NamedCounter(c.metricsScope, opName, "publish_errors", 1)
378376
return fmt.Errorf("failed to publish batch %s to merge: %w", batch.ID, err)

submitqueue/orchestrator/controller/speculate/run_test.go

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,10 +1406,9 @@ func memberHead() entity.Batch {
14061406
return h
14071407
}
14081408

1409-
// A head whose build passed but whose dependencies have not all settled is in
1410-
// the one part of speculation worth naming: its own work is done, and what
1411-
// remains is other batches finishing. Without this its members read as still
1412-
// building for the whole of that wait.
1409+
// A head whose build passed but whose dependencies have not all settled has
1410+
// nothing of its own left to run, a wait that reads very differently to still
1411+
// building.
14131412
func TestRun_ReportsPassedPathWhileWaiting(t *testing.T) {
14141413
ctrl := gomock.NewController(t)
14151414
passed := pathOver(entity.DependencyAssumptionSucceeds, entity.DependencyAssumptionSucceeds)
@@ -1434,15 +1433,15 @@ func TestRun_ReportsPassedPathWhileWaiting(t *testing.T) {
14341433

14351434
require.Len(t, h.logs, 1)
14361435
assert.Equal(t, "q/1", h.logs[0].RequestID)
1437-
assert.Equal(t, entity.RequestStatusSpeculated, h.logs[0].Status)
1436+
assert.Equal(t, entity.RequestLogTypeEvent, h.logs[0].Type)
1437+
assert.Equal(t, entity.RequestEventWaiting, h.logs[0].Event)
14381438
assert.Equal(t, head, h.logs[0].Metadata["batch_id"])
14391439
assert.Equal(t, entry.ID, h.logs[0].Metadata["path_id"])
14401440
}
14411441

14421442
// The other half: a dependency that resolves against a passed path's guess
1443-
// takes the head's waiting room away and puts it back to building. Reporting
1444-
// that is what stops the members reading as speculated through the rebuild.
1445-
func TestRun_ReportsBackToSpeculatingWhenPassedPathBreaks(t *testing.T) {
1443+
// takes the head's waiting room away.
1444+
func TestRun_ReportsInvalidatedWhenPassedPathBreaks(t *testing.T) {
14461445
ctrl := gomock.NewController(t)
14471446
passed := pathOver(entity.DependencyAssumptionSucceeds, entity.DependencyAssumptionFails)
14481447
spec := &scriptedSpeculator{}
@@ -1465,6 +1464,60 @@ func TestRun_ReportsBackToSpeculatingWhenPassedPathBreaks(t *testing.T) {
14651464
require.NoError(t, h.run(head))
14661465

14671466
require.Len(t, h.logs, 1)
1468-
assert.Equal(t, entity.RequestStatusSpeculating, h.logs[0].Status)
1467+
assert.Equal(t, entity.RequestLogTypeEvent, h.logs[0].Type)
1468+
assert.Equal(t, entity.RequestEventInvalidated, h.logs[0].Event)
14691469
assert.Equal(t, entry.ID, h.logs[0].Metadata["path_id"])
14701470
}
1471+
1472+
// A merge is decided on the same live passed path a wait would be reported
1473+
// from, so without the gate every landed request would carry a wait it never
1474+
// had.
1475+
func TestRun_MergingHeadReportsSpeculatedAndNoWait(t *testing.T) {
1476+
ctrl := gomock.NewController(t)
1477+
passed := pathOver(entity.DependencyAssumptionSucceeds, entity.DependencyAssumptionSucceeds)
1478+
1479+
h := newRunHarness(t, ctrl, &scriptedSpeculator{}, []entity.Batch{memberHead()})
1480+
h.noBuildsDispatched()
1481+
h.batches.EXPECT().Get(gomock.Any(), dep1).Return(entity.Batch{ID: dep1, State: entity.BatchStateSucceeded}, nil)
1482+
h.batches.EXPECT().Get(gomock.Any(), dep2).Return(entity.Batch{ID: dep2, State: entity.BatchStateSucceeded}, nil)
1483+
h.pathSets.EXPECT().Get(gomock.Any(), head).Return(entity.SpeculationPathSet{
1484+
Head: head,
1485+
Paths: []entity.SpeculationPathEntry{entryFor(passed, entity.SpeculationPathStatusPassed)},
1486+
Version: 1,
1487+
}, nil).AnyTimes()
1488+
h.batches.EXPECT().
1489+
Update(gomock.Any(), updateTo{id: head, state: entity.BatchStateMerging}, int32(1), int32(2)).Return(nil)
1490+
1491+
require.NoError(t, h.run(head))
1492+
1493+
require.Len(t, h.logs, 1)
1494+
assert.Equal(t, entity.RequestLogTypeStatus, h.logs[0].Type)
1495+
assert.Equal(t, entity.RequestStatusSpeculated, h.logs[0].Status)
1496+
assert.Equal(t, head, h.logs[0].Metadata["batch_id"])
1497+
}
1498+
1499+
// The merge stage publishes landing as its first act on the dispatch. Both
1500+
// statuses are non-terminal, so the summary is decided on timestamp alone and
1501+
// a speculated sent afterwards would beat the landing it precedes.
1502+
func TestRun_SpeculatedIsReportedBeforeTheMergeDispatch(t *testing.T) {
1503+
ctrl := gomock.NewController(t)
1504+
passed := pathOver(entity.DependencyAssumptionSucceeds, entity.DependencyAssumptionSucceeds)
1505+
1506+
h := newRunHarness(t, ctrl, &scriptedSpeculator{}, []entity.Batch{memberHead()})
1507+
h.failPublishTo("submitqueue-merge")
1508+
h.noBuildsDispatched()
1509+
h.batches.EXPECT().Get(gomock.Any(), dep1).Return(entity.Batch{ID: dep1, State: entity.BatchStateSucceeded}, nil)
1510+
h.batches.EXPECT().Get(gomock.Any(), dep2).Return(entity.Batch{ID: dep2, State: entity.BatchStateSucceeded}, nil)
1511+
h.pathSets.EXPECT().Get(gomock.Any(), head).Return(entity.SpeculationPathSet{
1512+
Head: head,
1513+
Paths: []entity.SpeculationPathEntry{entryFor(passed, entity.SpeculationPathStatusPassed)},
1514+
Version: 1,
1515+
}, nil).AnyTimes()
1516+
h.batches.EXPECT().
1517+
Update(gomock.Any(), updateTo{id: head, state: entity.BatchStateMerging}, int32(1), int32(2)).Return(nil)
1518+
1519+
require.Error(t, h.run(head))
1520+
1521+
require.Len(t, h.logs, 1)
1522+
assert.Equal(t, entity.RequestStatusSpeculated, h.logs[0].Status)
1523+
}

0 commit comments

Comments
 (0)