Skip to content

Commit aa14df2

Browse files
authored
feat(speculate): hold speculating until the batch can be sent to merge (#586)
## 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. The second commit adds the end-to-end coverage this had been missing. Nothing in `test/integration/` touches the speculate pipeline — the orchestrator integration suite is `TestPingAPI` and nothing else — and the e2e happy path has no dependencies, so it never speculates across one. `e2e-respeculate-queue` is registered in the gateway's queue list and deliberately takes no profile of its own: falling through to the baseline is what gives it the `all` analyzer, which serializes the queue so a second request becomes a batch depending on the first. The new test forces the wait rather than racing it. Batch IDs come from a per-queue counter as `<queue>/batch/<n>`, so on a fresh queue the leader is `batch/1`, and the build topic partitions by batch — closing the consumer gate on that partition before anything is published holds the leader's build and nothing else. The follower reaches a passed path while its dependency is still outstanding, reports `waiting`, and is asserted to still be `speculating`. Releasing the gate fails the leader, and the follower re-plans and lands with `speculating` and `speculated` recorded exactly once each. `invalidated` is deliberately not asserted end to end. A passed path stops occupying build budget, so by the time the leader fails the follower has usually funded the other side of the guess as well; it never loses its last live passed path, which is the state `invalidated` reports. Forcing that end to end would mean starving the queue's budget, which cannot be done without also starving the follower's first build. A unit test covers the case e2e cannot reach deterministically: a dependency turning terminal in the same run that walks the head resting on it, so the break is seen by a later generation of the finalize loop rather than by the read. A third commit adapts `TestDependentBatch_IsWokenByTheMergeAhead`, which #576 added to main after this branch was cut. It parks the lead batch's merge behind a closed gate, waits for the dependent to reach `speculated`, and only then opens the gate — an ordering that encodes the old meaning of `speculated`, which a batch could reach while its dependency was still outstanding. Under this change the dependent rests at `speculating` instead, so the test waited for a status that could not arrive until it opened the gate, and did not open the gate until it arrived. It now waits for the `waiting` event: the same fact it was reaching for, expressed as the signal that carries it, and reachable while the lead is still parked. The gate still opens next and both requests are still asserted to land, so the wake-up remains attributable to the fan-out alone. ## Test Plan ✅ `bazel test //submitqueue/... //platform/... //service/...` — 73 tests pass ✅ `bazel test //test/e2e/...` — 3/3 pass, including the new scenario ✅ `make lint`, `make check-gazelle`, `make check-tidy`, `make check-mocks` Re-verified after rebasing onto main at `42d1cb72`: `bazel test //submitqueue/... //platform/...` — 69 tests pass; `bazel test //test/e2e/submitqueue` — passes in 120s, against the 300s timeout it hit before the third commit. The two `reportSpeculation` tests now assert events. New unit coverage: a merging head reports `speculated` and no wait (the gate's regression test); `speculated` is published before the `TopicKeyMerge` dispatch; and the same-run cascade reports `invalidated`. `TestLand_HappyPath_ReachesLanded` 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 ## Stack 1. #585 1. @ #586
1 parent 42d1cb7 commit aa14df2

6 files changed

Lines changed: 271 additions & 61 deletions

File tree

service/submitqueue/gateway/server/queues.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@ queues:
2323
# pipeline runs against a real repository. See
2424
# service/submitqueue/demo/provider and doc/howto/PROVIDER-E2E.md.
2525
- name: demo-queue
26+
# Inherits the baseline "all" analyzer, which serializes the queue, so a
27+
# second request lands as a batch depending on the first. e2e uses that to
28+
# exercise speculation across an unresolved dependency.
29+
- name: e2e-respeculate-queue

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: 110 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,108 @@ 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+
// The e2e shape: the dependency turns terminal in the same run that walks the
1473+
// head resting on it, so the break is seen by a later generation of the loop
1474+
// rather than by the read.
1475+
func TestRun_ReportsInvalidatedWhenTheDependencyFailsInTheSameRun(t *testing.T) {
1476+
ctrl := gomock.NewController(t)
1477+
1478+
leader := entity.Batch{ID: dep1, Queue: "q", State: entity.BatchStateSpeculating, Version: 1}
1479+
followerBatch := entity.Batch{
1480+
ID: head, Queue: "q", Contains: []string{"q/1"},
1481+
State: entity.BatchStateSpeculating, Dependencies: []string{dep1}, Version: 1,
1482+
}
1483+
1484+
h := newRunHarness(t, ctrl, &scriptedSpeculator{}, []entity.Batch{leader, followerBatch})
1485+
h.noBuildsDispatched()
1486+
1487+
// The leader has nothing left that can pass, so this run fails it.
1488+
h.pathSets.EXPECT().Get(gomock.Any(), dep1).Return(entity.SpeculationPathSet{
1489+
Head: dep1,
1490+
Paths: []entity.SpeculationPathEntry{entryFor(entity.SpeculationPath{Head: dep1}, entity.SpeculationPathStatusFailed)},
1491+
Version: 1,
1492+
}, nil).AnyTimes()
1493+
h.batches.EXPECT().
1494+
Update(gomock.Any(), updateTo{id: dep1, state: entity.BatchStateFailed}, int32(1), int32(2)).Return(nil)
1495+
1496+
// The follower passed on the guess that the leader would succeed.
1497+
passed := entity.SpeculationPath{
1498+
Head: head,
1499+
Dependencies: []entity.PathDependency{{Batch: dep1, Assumption: entity.DependencyAssumptionSucceeds}},
1500+
}
1501+
entry := entryFor(passed, entity.SpeculationPathStatusPassed)
1502+
h.pathSets.EXPECT().Get(gomock.Any(), head).Return(entity.SpeculationPathSet{
1503+
Head: head,
1504+
Paths: []entity.SpeculationPathEntry{entry},
1505+
Version: 1,
1506+
}, nil).AnyTimes()
1507+
h.pathSets.EXPECT().Update(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
1508+
1509+
require.NoError(t, h.run(dep1))
1510+
1511+
var got []entity.RequestEvent
1512+
for _, entry := range h.logs {
1513+
if entry.Type == entity.RequestLogTypeEvent {
1514+
got = append(got, entry.Event)
1515+
}
1516+
}
1517+
assert.Contains(t, got, entity.RequestEventInvalidated)
1518+
}
1519+
1520+
// A merge is decided on the same live passed path a wait would be reported
1521+
// from, so without the gate every landed request would carry a wait it never
1522+
// had.
1523+
func TestRun_MergingHeadReportsSpeculatedAndNoWait(t *testing.T) {
1524+
ctrl := gomock.NewController(t)
1525+
passed := pathOver(entity.DependencyAssumptionSucceeds, entity.DependencyAssumptionSucceeds)
1526+
1527+
h := newRunHarness(t, ctrl, &scriptedSpeculator{}, []entity.Batch{memberHead()})
1528+
h.noBuildsDispatched()
1529+
h.batches.EXPECT().Get(gomock.Any(), dep1).Return(entity.Batch{ID: dep1, State: entity.BatchStateSucceeded}, nil)
1530+
h.batches.EXPECT().Get(gomock.Any(), dep2).Return(entity.Batch{ID: dep2, State: entity.BatchStateSucceeded}, nil)
1531+
h.pathSets.EXPECT().Get(gomock.Any(), head).Return(entity.SpeculationPathSet{
1532+
Head: head,
1533+
Paths: []entity.SpeculationPathEntry{entryFor(passed, entity.SpeculationPathStatusPassed)},
1534+
Version: 1,
1535+
}, nil).AnyTimes()
1536+
h.batches.EXPECT().
1537+
Update(gomock.Any(), updateTo{id: head, state: entity.BatchStateMerging}, int32(1), int32(2)).Return(nil)
1538+
1539+
require.NoError(t, h.run(head))
1540+
1541+
require.Len(t, h.logs, 1)
1542+
assert.Equal(t, entity.RequestLogTypeStatus, h.logs[0].Type)
1543+
assert.Equal(t, entity.RequestStatusSpeculated, h.logs[0].Status)
1544+
assert.Equal(t, head, h.logs[0].Metadata["batch_id"])
1545+
}
1546+
1547+
// The merge stage publishes landing as its first act on the dispatch. Both
1548+
// statuses are non-terminal, so the summary is decided on timestamp alone and
1549+
// a speculated sent afterwards would beat the landing it precedes.
1550+
func TestRun_SpeculatedIsReportedBeforeTheMergeDispatch(t *testing.T) {
1551+
ctrl := gomock.NewController(t)
1552+
passed := pathOver(entity.DependencyAssumptionSucceeds, entity.DependencyAssumptionSucceeds)
1553+
1554+
h := newRunHarness(t, ctrl, &scriptedSpeculator{}, []entity.Batch{memberHead()})
1555+
h.failPublishTo("submitqueue-merge")
1556+
h.noBuildsDispatched()
1557+
h.batches.EXPECT().Get(gomock.Any(), dep1).Return(entity.Batch{ID: dep1, State: entity.BatchStateSucceeded}, nil)
1558+
h.batches.EXPECT().Get(gomock.Any(), dep2).Return(entity.Batch{ID: dep2, State: entity.BatchStateSucceeded}, nil)
1559+
h.pathSets.EXPECT().Get(gomock.Any(), head).Return(entity.SpeculationPathSet{
1560+
Head: head,
1561+
Paths: []entity.SpeculationPathEntry{entryFor(passed, entity.SpeculationPathStatusPassed)},
1562+
Version: 1,
1563+
}, nil).AnyTimes()
1564+
h.batches.EXPECT().
1565+
Update(gomock.Any(), updateTo{id: head, state: entity.BatchStateMerging}, int32(1), int32(2)).Return(nil)
1566+
1567+
require.Error(t, h.run(head))
1568+
1569+
require.Len(t, h.logs, 1)
1570+
assert.Equal(t, entity.RequestStatusSpeculated, h.logs[0].Status)
1571+
}

test/e2e/submitqueue/harness_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,46 @@ func (s *E2EIntegrationSuite) awaitBatchID(req request) string {
207207
return batchID
208208
}
209209

210+
// mustStatus reads the current status and fails the test if it is unreadable.
211+
func (s *E2EIntegrationSuite) mustStatus(req request) entity.RequestStatus {
212+
t := s.T()
213+
got, err := s.currentStatus(req)
214+
require.NoError(t, err, "GetRequestSummaryByID failed for %s", req.sqid)
215+
return got
216+
}
217+
218+
// awaitEvent polls GetRequestHistoryByID until want appears in the request's
219+
// event timeline. Unlike a status, an event is never the current position, so
220+
// there is nothing to poll on the summary — the history is the only witness.
221+
func (s *E2EIntegrationSuite) awaitEvent(req request, want entity.RequestEvent) {
222+
pollUntil(persistPollInterval, func() bool {
223+
got := s.eventTimeline(req)
224+
s.log.Logf("events(%s) = %v (want %q)", req.sqid, got, want)
225+
for _, e := range got {
226+
if e == want {
227+
return true
228+
}
229+
}
230+
return false
231+
})
232+
}
233+
234+
// assertStatusCount asserts how many times a status appears in the timeline.
235+
// A status that recurs is not merely noisy: the client renders each entry as a
236+
// fresh step, so a stage revisited reads as the pipeline going backwards.
237+
func (s *E2EIntegrationSuite) assertStatusCount(req request, status entity.RequestStatus, want int) {
238+
t := s.T()
239+
got := s.timeline(req)
240+
seen := 0
241+
for _, st := range got {
242+
if st == status {
243+
seen++
244+
}
245+
}
246+
assert.Equalf(t, want, seen,
247+
"GetRequestHistoryByID for %s should record %q %d time(s); got %v", req.sqid, status, want, got)
248+
}
249+
210250
// closeGate closes the consumer gate for the consumer group, scoped to one
211251
// partition (the queue name for pipeline topics). The gate must be closed
212252
// before the message that must be caught is published — that makes the stop

0 commit comments

Comments
 (0)