Skip to content

Commit 09e73e3

Browse files
sbalabanov-zzclaude
andcommitted
test(e2e): event-driven harness with per-controller parking and deterministic cancel test
Rework the SubmitQueue e2e suite so tests block on signals the pipeline pushes to them instead of polling with hard-coded timeouts, add a per-controller parking primitive so any pipeline stage can be starved deterministically, and add a cancellation test that deterministically reaches the terminal "cancelled" state. - Queue observer (observer_test.go): test-owned consumer groups subscribe to the log topic and runway's check/signal topics over the published mysql-queue port. Delivery state is keyed per consumer group, so the observer receives a copy of every message without stealing from the services and without any service changes. Tests await pushed events (status transitions, runway's answers on the wire). - No hard-coded timeouts: every wait is bounded by a suite context whose deadline derives from the test binary deadline (Bazel test timeout) minus a teardown margin. The old persistTimeout constant is gone; the remaining polls (Status RPC and request_log, both eventually consistent behind the gateway log consumer) run at a fixed cadence under that context. - StageHold (testutil/stagehold.go): parks any single queue consumer controller with zero service changes by planting a phantom partition lease (far-future lease_renewed_at) in the MySQL queue's partition-leases table for (consumer_group, topic, partition_key). The plain INSERT fails loudly if the partition is already leased, so holds must be planted before the stage first consumes (pre-hold contract). Release() deletes the row (idempotent after success, retryable on failure) and consumption resumes on the service's next discovery tick. Held messages are never delivered, so parking has no retry-budget or DLQ interaction. - Stage catalog (stage_test.go): enumerates every queue consumer controller across the three services — gateway (1), orchestrator (12 primary + 12 DLQ), and runway (2) — with a holdStage() suite helper, so tests can starve any controller x partition. - Lease-semantics contract test (integration, messagequeue/mysql): pins the guarantees StageHold relies on — a held partition starves the group's consumer while other partitions keep flowing, releasing the hold resumes delivery, and Release is idempotent. - Deterministic cancel test: park the request at the merge-conflict-check boundary with a StageHold on runway's merge-conflict-check consumer (partition = queue name) instead of stopping/starting the runway container (confirmed by a pushed event), cancel, await the pushed "cancelled" event, then assert the terminal store state, that no batch ever enrolled the request, and that runway's late signal after the hold is released does not resurrect it. The connectRunway re-dial helper is gone: services start once in SetupSuite and stop once in TearDownSuite. - Runway is now first-class in the suite: gRPC ping test, graceful shutdown exit-code check, and happy-path assertions that runway answered both the merge-conflict check and the merge with SUCCEEDED (the merge signal must carry the request's step IDs — the assertion that exposed the merge topic collision fixed in the parent commit). - testutil: ComposeStack drops StartService — there is no on-demand service start/stop mid-suite anymore (StopService is retained for the graceful-shutdown verification in teardown); compose command prefers docker compose V2 (the harness relies on V2-only "up --wait"); the stack uses a background context so teardown outlives the suite wait budget; TestLogger is safe for concurrent use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d6ce535 commit 09e73e3

11 files changed

Lines changed: 1154 additions & 136 deletions

File tree

test/e2e/submitqueue/BUILD.bazel

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ load("@rules_go//go:def.bzl", "go_test")
22

33
go_test(
44
name = "go_default_test",
5+
# First run builds the service images inside compose up; give the suite a
6+
# 900s budget (the suite context derives every wait bound from it).
7+
timeout = "long",
58
srcs = [
69
"harness_test.go",
10+
"observer_test.go",
11+
"stage_test.go",
712
"suite_test.go",
813
],
914
data = [
@@ -22,8 +27,16 @@ go_test(
2227
deps = [
2328
"//api/base/change/protopb:go_default_library",
2429
"//api/base/mergestrategy/protopb:go_default_library",
30+
"//api/runway/messagequeue:go_default_library",
31+
"//api/runway/messagequeue/protopb:go_default_library",
32+
"//api/runway/protopb:go_default_library",
2533
"//api/submitqueue/gateway/protopb:go_default_library",
2634
"//api/submitqueue/orchestrator/protopb:go_default_library",
35+
"//platform/consumer:go_default_library",
36+
"//platform/errs:go_default_library",
37+
"//platform/extension/messagequeue:go_default_library",
38+
"//platform/extension/messagequeue/mysql:go_default_library",
39+
"//submitqueue/core/topickey:go_default_library",
2740
"//submitqueue/entity:go_default_library",
2841
"//submitqueue/extension/storage:go_default_library",
2942
"//submitqueue/extension/storage/mysql:go_default_library",
@@ -35,5 +48,6 @@ go_test(
3548
"@org_golang_google_grpc//:go_default_library",
3649
"@org_golang_google_grpc//codes:go_default_library",
3750
"@org_golang_google_grpc//status:go_default_library",
51+
"@org_uber_go_zap//:go_default_library",
3852
],
3953
)

test/e2e/submitqueue/harness_test.go

Lines changed: 170 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -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

3038
import (
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.
56161
func (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).
146267
func (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

Comments
 (0)