@@ -16,26 +16,49 @@ package e2e_test
1616
1717// Reusable e2e helpers so tests read as intent, not plumbing. They drive the
1818// stack through the real gateway gRPC surface (Land / Cancel / Status) and
19- // observe outcomes two ways :
19+ // observe outcomes on four read-only planes :
2020//
21- // - black-box, by polling the Status RPC to a target/terminal status; and
22- // - white-box, by reading the request_log timeline (RequestLogStore.List on
23- // mysql-app) to assert the ordered stage progression.
21+ // - event plane: pushed events from the queue observer (observer_test.go) —
22+ // status transitions on the log topic, runway's check/merge answers on the
23+ // signal topics. await* helpers block on these; no polling.
24+ // - control plane: the Status RPC, the customer-facing view. It reads the
25+ // request_log the gateway's log consumer persists, so it lags the published
26+ // event; awaitStatusRPC bridges the gap with a ctx-bounded poll.
27+ // - state plane: the operating stores on mysql-app (request, batch). The
28+ // orchestrator CAS-writes state *before* publishing the corresponding log
29+ // event, so a state read placed after an observed event is deterministic.
30+ // - timeline: the request_log audit trail on mysql-app, persisted
31+ // asynchronously by the gateway's log consumer; awaitStatusesInOrder polls
32+ // it to convergence.
2433//
25- // Convergence is bounded by require.Eventually (persistTimeout /
26- // persistPollInterval) rather than time.Sleep: the pipeline consumers run inside
27- // containers, so there is no in-process signal to await; a timeout here means a
28- // stage is genuinely stuck, not a timing race.
34+ // Every wait is bounded by the suite context deadline (derived from the test
35+ // binary's own deadline in suite_test.go) — there are no per-wait timeout
36+ // constants. pollInterval below is a poll cadence, not a timeout.
2937
3038import (
39+ "time"
40+
3141 "github.com/stretchr/testify/assert"
3242 "github.com/stretchr/testify/require"
3343 changepb "github.com/uber/submitqueue/api/base/change/protopb"
3444 mergestrategypb "github.com/uber/submitqueue/api/base/mergestrategy/protopb"
45+ runwaymq "github.com/uber/submitqueue/api/runway/messagequeue"
46+ runwaymqpb "github.com/uber/submitqueue/api/runway/messagequeue/protopb"
3547 gatewaypb "github.com/uber/submitqueue/api/submitqueue/gateway/protopb"
48+ "github.com/uber/submitqueue/submitqueue/core/topickey"
3649 "github.com/uber/submitqueue/submitqueue/entity"
3750)
3851
52+ // pollInterval is the cadence of the ctx-bounded polls against the control
53+ // plane and the request_log (both are eventually consistent with the event
54+ // plane). It bounds how often we re-query, never how long we wait.
55+ const pollInterval = 250 * time .Millisecond
56+
57+ // fallbackWaitBudget bounds eventually() only when the test binary runs with
58+ // no deadline at all (go test -timeout=0); under Bazel the deadline is always
59+ // set from the test target's timeout and this value is unused.
60+ const fallbackWaitBudget = time .Hour
61+
3962// land submits a request with the default REBASE strategy and returns its sqid.
4063// URIs may carry "sq-fake=<token>" markers to steer negative paths (see
4164// submitqueue/core/fakemarker); the happy path uses a plain change URI.
@@ -51,6 +74,88 @@ func (s *E2EIntegrationSuite) land(queue string, uris ...string) string {
5174 return resp .Sqid
5275}
5376
77+ // cancel requests cancellation of a request via the gateway.
78+ func (s * E2EIntegrationSuite ) cancel (sqid , reason string ) {
79+ _ , err := s .gatewayClient .Cancel (s .ctx , & gatewaypb.CancelRequest {Sqid : sqid , Reason : reason })
80+ require .NoError (s .T (), err , "Cancel failed for %s" , sqid )
81+ }
82+
83+ // eventually polls cond at pollInterval until it returns true, bounded by the
84+ // suite context deadline (the single wait budget for the whole suite).
85+ func (s * E2EIntegrationSuite ) eventually (cond func () bool , msgAndArgs ... interface {}) {
86+ s .T ().Helper ()
87+ waitFor := fallbackWaitBudget
88+ if deadline , ok := s .ctx .Deadline (); ok {
89+ waitFor = time .Until (deadline )
90+ }
91+ require .Eventually (s .T (), cond , waitFor , pollInterval , msgAndArgs ... )
92+ }
93+
94+ // awaitEvent blocks until the observer has recorded an event matching match.
95+ // desc names the awaited condition in the failure message.
96+ func (s * E2EIntegrationSuite ) awaitEvent (desc string , match func (pipelineEvent ) bool ) pipelineEvent {
97+ s .T ().Helper ()
98+ ev , err := s .observer .recorder .await (s .ctx , match )
99+ require .NoErrorf (s .T (), err , "no %s observed before the suite deadline" , desc )
100+ return ev
101+ }
102+
103+ // awaitStatusEvent blocks until the pipeline publishes the given status for the
104+ // request on the log topic. Because every orchestrator controller CAS-writes
105+ // request state before publishing the matching log entry, state-plane reads
106+ // placed after this call observe the corresponding state deterministically.
107+ func (s * E2EIntegrationSuite ) awaitStatusEvent (sqid string , want entity.RequestStatus ) {
108+ s .T ().Helper ()
109+ s .log .Logf ("Awaiting %q status event for %s" , want , sqid )
110+ s .awaitEvent (string (want )+ " status event for " + sqid , func (e pipelineEvent ) bool {
111+ return e .topic == topickey .TopicKeyLog && e .requestID == sqid && e .status == want
112+ })
113+ }
114+
115+ // awaitCheckRequested blocks until the orchestrator hands the request's
116+ // merge-conflict check to runway (validate published to the runway-owned
117+ // merge-conflict-check topic). This is the "parked at the runway boundary"
118+ // marker: with runway paused, a request that reached this point can make no
119+ // further forward progress until runway resumes.
120+ func (s * E2EIntegrationSuite ) awaitCheckRequested (sqid string ) {
121+ s .T ().Helper ()
122+ s .log .Logf ("Awaiting merge-conflict-check request for %s" , sqid )
123+ s .awaitEvent ("merge-conflict-check request for " + sqid , func (e pipelineEvent ) bool {
124+ return e .topic == runwaymq .TopicKeyMergeConflictCheck && e .requestID == sqid
125+ })
126+ }
127+
128+ // awaitCheckSignal blocks until runway publishes the merge-conflict-check
129+ // result for the request and returns it. Proof on the wire that runway consumed
130+ // and answered the check.
131+ func (s * E2EIntegrationSuite ) awaitCheckSignal (sqid string ) pipelineEvent {
132+ s .T ().Helper ()
133+ s .log .Logf ("Awaiting merge-conflict-check signal for %s" , sqid )
134+ return s .awaitEvent ("merge-conflict-check signal for " + sqid , func (e pipelineEvent ) bool {
135+ return e .topic == runwaymq .TopicKeyMergeConflictCheckSignal && e .requestID == sqid
136+ })
137+ }
138+
139+ // awaitMergeSignal blocks until runway publishes a committing-merge result
140+ // covering the request and returns it. Merge results carry the batch ID as the
141+ // top-level correlation ID; the request is matched through the step IDs, which
142+ // the orchestrator sets to the request sqid.
143+ func (s * E2EIntegrationSuite ) awaitMergeSignal (sqid string ) pipelineEvent {
144+ s .T ().Helper ()
145+ s .log .Logf ("Awaiting merge signal covering %s" , sqid )
146+ return s .awaitEvent ("merge signal covering " + sqid , func (e pipelineEvent ) bool {
147+ if e .topic != runwaymq .TopicKeyMergeSignal {
148+ return false
149+ }
150+ for _ , id := range e .stepIDs {
151+ if id == sqid {
152+ return true
153+ }
154+ }
155+ return false
156+ })
157+ }
158+
54159// currentStatus reads the request's current customer-facing status via the
55160// Status RPC. A transport error is returned so callers can keep polling.
56161func (s * E2EIntegrationSuite ) currentStatus (sqid string ) (entity.RequestStatus , error ) {
@@ -61,42 +166,23 @@ func (s *E2EIntegrationSuite) currentStatus(sqid string) (entity.RequestStatus,
61166 return entity .RequestStatus (resp .Status ), nil
62167}
63168
64- // awaitStatus polls Status until the request reaches exactly want.
65- func (s * E2EIntegrationSuite ) awaitStatus (sqid string , want entity.RequestStatus ) {
66- t := s .T ()
67- require .Eventually (t , func () bool {
169+ // awaitStatusRPC polls the Status RPC until it reports want. The Status RPC
170+ // reads the request_log persisted by the gateway's log consumer, which lags the
171+ // published event the test already observed — this bridges that persistence
172+ // gap; it is not the primary wait.
173+ func (s * E2EIntegrationSuite ) awaitStatusRPC (sqid string , want entity.RequestStatus ) {
174+ s .T ().Helper ()
175+ s .eventually (func () bool {
68176 got , err := s .currentStatus (sqid )
69177 if err != nil {
70178 s .log .Logf ("Status(%s) not ready yet: %v" , sqid , err )
71179 return false
72180 }
73181 s .log .Logf ("Status(%s) = %q (want %q)" , sqid , got , want )
74182 return got == want
75- }, persistTimeout , persistPollInterval ,
76- "request %s should reach status %q" , sqid , want )
183+ }, "request %s should reach status %q on the Status RPC" , sqid , want )
77184}
78185
79- // awaitTerminal polls Status until the request reaches a terminal status
80- // (landed, error, or cancelled) and returns it.
81- func (s * E2EIntegrationSuite ) awaitTerminal (sqid string ) entity.RequestStatus {
82- t := s .T ()
83- var last entity.RequestStatus
84- require .Eventually (t , func () bool {
85- got , err := s .currentStatus (sqid )
86- if err != nil {
87- s .log .Logf ("Status(%s) not ready yet: %v" , sqid , err )
88- return false
89- }
90- last = got
91- s .log .Logf ("Status(%s) = %q (awaiting terminal)" , sqid , got )
92- return isTerminalStatus (got )
93- }, persistTimeout , persistPollInterval ,
94- "request %s should reach a terminal status" , sqid )
95- return last
96- }
97-
98- // timeline returns the ordered status history from the request_log (the audit
99- // trail persisted by the gateway log consumer on mysql-app).
100186// timeline returns the ordered status history from the request_log (the audit
101187// trail persisted by the gateway log consumer on mysql-app). These are the
102188// customer-facing RequestStatus values — the only ordered history in the system
@@ -112,22 +198,43 @@ func (s *E2EIntegrationSuite) timeline(sqid string) []entity.RequestStatus {
112198 return statuses
113199}
114200
115- // assertStatusesInOrder asserts that want appears as an ordered subsequence of
116- // the request_log status timeline. It tolerates intermediate statuses (so it is
117- // not a change-detector), asserting only the relative order of the statuses that
118- // matter.
119- func (s * E2EIntegrationSuite ) assertStatusesInOrder (sqid string , want ... entity.RequestStatus ) {
120- t := s .T ()
121- got := s .timeline (sqid )
201+ // containsInOrder reports whether want appears as an ordered subsequence of got.
202+ func containsInOrder (got []entity.RequestStatus , want []entity.RequestStatus ) bool {
122203 matched := 0
123204 for _ , st := range got {
124205 if matched < len (want ) && st == want [matched ] {
125206 matched ++
126207 }
127208 }
128- assert .Equalf (t , len (want ), matched ,
129- "request_log for %s should contain %v as an ordered subsequence; got %v" ,
130- sqid , want , got )
209+ return matched == len (want )
210+ }
211+
212+ // awaitStatusesInOrder polls the request_log until want appears as an ordered
213+ // subsequence of the persisted status timeline. It tolerates intermediate
214+ // statuses (so it is not a change-detector), asserting only the relative order
215+ // of the statuses that matter. Polling (rather than a pure event await) is
216+ // needed because persistence into the request_log is the gateway log consumer's
217+ // job and lags the events the observer sees.
218+ func (s * E2EIntegrationSuite ) awaitStatusesInOrder (sqid string , want ... entity.RequestStatus ) {
219+ s .T ().Helper ()
220+ s .eventually (func () bool {
221+ got := s .timeline (sqid )
222+ if containsInOrder (got , want ) {
223+ return true
224+ }
225+ s .log .Logf ("request_log for %s not yet %v; currently %v" , sqid , want , got )
226+ return false
227+ }, "request_log for %s should contain %v as an ordered subsequence" , sqid , want )
228+ }
229+
230+ // assertStatusNeverLogged asserts that the persisted timeline contains no entry
231+ // with the given status. Call it after the terminal status has been persisted
232+ // (awaitStatusRPC/awaitStatusesInOrder), when the timeline is complete.
233+ func (s * E2EIntegrationSuite ) assertStatusNeverLogged (sqid string , status entity.RequestStatus ) {
234+ s .T ().Helper ()
235+ got := s .timeline (sqid )
236+ assert .NotContainsf (s .T (), got , status ,
237+ "request_log for %s should never contain %q; got %v" , sqid , status , got )
131238}
132239
133240// terminalState reads the request's current internal RequestState from the
@@ -141,6 +248,20 @@ func (s *E2EIntegrationSuite) terminalState(sqid string) entity.RequestState {
141248 return req .State
142249}
143250
251+ // assertNoBatchContains asserts that no batch in the queue — active or terminal
252+ // — ever enrolled the request.
253+ func (s * E2EIntegrationSuite ) assertNoBatchContains (queue , sqid string ) {
254+ t := s .T ()
255+ allStates := append (entity .ActiveBatchStates (),
256+ entity .BatchStateSucceeded , entity .BatchStateFailed , entity .BatchStateCancelled )
257+ batches , err := s .batchStore .GetByQueueAndStates (s .ctx , queue , allStates )
258+ require .NoError (t , err , "failed to list batches for queue %s" , queue )
259+ for _ , b := range batches {
260+ assert .NotContainsf (t , b .Contains , sqid ,
261+ "batch %s should not contain request %s" , b .ID , sqid )
262+ }
263+ }
264+
144265// lastError returns the LastError reported by the Status RPC (populated on the
145266// error path).
146267func (s * E2EIntegrationSuite ) lastError (sqid string ) string {
@@ -150,12 +271,8 @@ func (s *E2EIntegrationSuite) lastError(sqid string) string {
150271 return resp .LastError
151272}
152273
153- // isTerminalStatus reports whether a customer-facing status is terminal.
154- func isTerminalStatus (status entity.RequestStatus ) bool {
155- switch status {
156- case entity .RequestStatusLanded , entity .RequestStatusError , entity .RequestStatusCancelled :
157- return true
158- default :
159- return false
160- }
274+ // assertOutcomeSucceeded asserts a runway signal event reports SUCCEEDED.
275+ func (s * E2EIntegrationSuite ) assertOutcomeSucceeded (ev pipelineEvent , what string ) {
276+ assert .Equalf (s .T (), runwaymqpb .Outcome_SUCCEEDED , ev .outcome ,
277+ "%s should report SUCCEEDED, got %s" , what , ev .outcome )
161278}
0 commit comments