Skip to content

Commit 3a6057c

Browse files
authored
test(e2e): drive submitqueue request Land→landed with runway in the stack (#297)
## Summary ### Why? The e2e suite barely validated anything: pings, a Land that only checked a non-empty sqid, one test that stopped at status `started`, and a thin cancel smoke test with a standing TODO. It also could not exercise the full pipeline, because `service/submitqueue/docker-compose.yml` did not include runway — the orchestrator blocks on runway's merge-conflict-check and merge signals, so every request stalled at `started`. ### What? Add `runway-service` to `service/submitqueue/docker-compose.yml`, sharing the existing `mysql-queue` (no app DB). Runway already publishes SUCCEEDED signals via its Merger extension (the noop merger wired in `service/runway/server`), so with it in the stack a request now flows all the way to `landed`. Add a reusable e2e harness (`test/e2e/submitqueue/harness_test.go`): `land`, `awaitStatus`, `awaitTerminal`, `timeline` + `assertStatusesInOrder` (white-box ordered `request_log` status history, tolerant subsequence), `terminalState` (white-box internal `RequestState` from the operating store), and `lastError` — so new per-stage and error-path tests drop in as a few lines. Rewrite the suite: `TestLand_HappyPath_ReachesLanded` drives a request to terminal success and asserts three views — the black-box `landed` Status, the ordered status history `accepted → started → batched → scored → landed`, and the operating store's internal `RequestStateLanded` (this also covers the request-log ownership invariant: every status but the synchronous `accepted` reaches storage only via the orchestrator-publishes / gateway-persists log path). The pings and graceful-shutdown checks are kept. The cancel test asserts the deterministic half (Cancel returns OK and the gateway synchronously records the `cancelling` intent). Asserting terminal `cancelled` is racy — a cancel processed before the orchestrator's start controller creates the request is rejected to the DLQ and reconciled to `error` — and needs a pipeline-pause lever, deferred to the next per-stage increment. ## Test Plan ✅ `make e2e-test` — full Docker stack (gateway + orchestrator + runway + 2 MySQL) passes; a request reaches `landed` end to end. ✅ `make fmt`; `make check-gazelle` / `make check-tidy` verified clean (only intended edits).
1 parent 5607a83 commit 3a6057c

4 files changed

Lines changed: 263 additions & 76 deletions

File tree

service/submitqueue/docker-compose.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,23 @@ services:
8181
condition: service_healthy
8282
mysql-queue:
8383
condition: service_healthy
84+
85+
# Runway performs the merge-conflict check and the committing merge. The
86+
# orchestrator hands work to it on runway-owned topics and blocks on the
87+
# signal topics, so without runway a request stalls at "started". It only
88+
# needs the queue DB (shared with the orchestrator over mysql-queue); it has
89+
# no application database of its own.
90+
runway-service:
91+
build:
92+
context: ${REPO_ROOT}
93+
dockerfile: service/runway/server/Dockerfile
94+
ports:
95+
- "8080" # Random ephemeral port to avoid conflicts
96+
environment:
97+
- PORT=:8080
98+
# Queue infrastructure connection (shared with the orchestrator)
99+
- QUEUE_MYSQL_DSN=root:root@tcp(mysql-queue:3306)/submitqueue?parseTime=true
100+
- HOSTNAME=runway-dev
101+
depends_on:
102+
mysql-queue:
103+
condition: service_healthy

test/e2e/submitqueue/BUILD.bazel

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ load("@rules_go//go:def.bzl", "go_test")
22

33
go_test(
44
name = "submitqueue_test",
5-
srcs = ["suite_test.go"],
5+
srcs = [
6+
"harness_test.go",
7+
"suite_test.go",
8+
],
69
data = [
710
"//:MODULE.bazel",
811
"//:go.mod",
@@ -22,10 +25,13 @@ go_test(
2225
"//api/submitqueue/gateway/protopb",
2326
"//api/submitqueue/orchestrator/protopb",
2427
"//submitqueue/entity",
28+
"//submitqueue/extension/storage",
29+
"//submitqueue/extension/storage/mysql",
2530
"//test/testutil",
2631
"@com_github_stretchr_testify//assert",
2732
"@com_github_stretchr_testify//require",
2833
"@com_github_stretchr_testify//suite",
34+
"@com_github_uber_go_tally//:tally",
2935
"@org_golang_google_grpc//:grpc",
3036
"@org_golang_google_grpc//codes",
3137
"@org_golang_google_grpc//status",
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package e2e_test
16+
17+
// Reusable e2e helpers so tests read as intent, not plumbing. They drive the
18+
// stack through the real gateway gRPC surface (Land / Cancel / Status) and
19+
// observe outcomes two ways:
20+
//
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.
24+
//
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.
29+
30+
import (
31+
"github.com/stretchr/testify/assert"
32+
"github.com/stretchr/testify/require"
33+
changepb "github.com/uber/submitqueue/api/base/change/protopb"
34+
mergestrategypb "github.com/uber/submitqueue/api/base/mergestrategy/protopb"
35+
gatewaypb "github.com/uber/submitqueue/api/submitqueue/gateway/protopb"
36+
"github.com/uber/submitqueue/submitqueue/entity"
37+
)
38+
39+
// land submits a request with the default REBASE strategy and returns its sqid.
40+
// URIs may carry "sq-fake=<token>" markers to steer negative paths (see
41+
// submitqueue/core/fakemarker); the happy path uses a plain change URI.
42+
func (s *E2EIntegrationSuite) land(queue string, uris ...string) string {
43+
t := s.T()
44+
resp, err := s.gatewayClient.Land(s.ctx, &gatewaypb.LandRequest{
45+
Queue: queue,
46+
Change: &changepb.Change{Uris: uris},
47+
Strategy: mergestrategypb.Strategy_REBASE,
48+
})
49+
require.NoError(t, err, "Land failed for queue %s", queue)
50+
require.NotEmpty(t, resp.Sqid, "Land returned an empty sqid for queue %s", queue)
51+
return resp.Sqid
52+
}
53+
54+
// currentStatus reads the request's current customer-facing status via the
55+
// Status RPC. A transport error is returned so callers can keep polling.
56+
func (s *E2EIntegrationSuite) currentStatus(sqid string) (entity.RequestStatus, error) {
57+
resp, err := s.gatewayClient.Status(s.ctx, &gatewaypb.StatusRequest{Sqid: sqid})
58+
if err != nil {
59+
return entity.RequestStatusUnknown, err
60+
}
61+
return entity.RequestStatus(resp.Status), nil
62+
}
63+
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 {
68+
got, err := s.currentStatus(sqid)
69+
if err != nil {
70+
s.log.Logf("Status(%s) not ready yet: %v", sqid, err)
71+
return false
72+
}
73+
s.log.Logf("Status(%s) = %q (want %q)", sqid, got, want)
74+
return got == want
75+
}, persistTimeout, persistPollInterval,
76+
"request %s should reach status %q", sqid, want)
77+
}
78+
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).
100+
// timeline returns the ordered status history from the request_log (the audit
101+
// trail persisted by the gateway log consumer on mysql-app). These are the
102+
// customer-facing RequestStatus values — the only ordered history in the system
103+
// (the internal RequestState is point-in-time, see terminalState).
104+
func (s *E2EIntegrationSuite) timeline(sqid string) []entity.RequestStatus {
105+
t := s.T()
106+
logs, err := s.requestLog.List(s.ctx, sqid)
107+
require.NoError(t, err, "failed to list request_log for %s", sqid)
108+
statuses := make([]entity.RequestStatus, len(logs))
109+
for i, l := range logs {
110+
statuses[i] = l.Status
111+
}
112+
return statuses
113+
}
114+
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)
122+
matched := 0
123+
for _, st := range got {
124+
if matched < len(want) && st == want[matched] {
125+
matched++
126+
}
127+
}
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)
131+
}
132+
133+
// terminalState reads the request's current internal RequestState from the
134+
// operating store (mysql-app). Unlike the status timeline, RequestState is
135+
// point-in-time — the Request entity is updated in place under optimistic
136+
// locking, so only the current (terminal, once settled) value is observable.
137+
func (s *E2EIntegrationSuite) terminalState(sqid string) entity.RequestState {
138+
t := s.T()
139+
req, err := s.requestStore.Get(s.ctx, sqid)
140+
require.NoError(t, err, "failed to get request %s from operating store", sqid)
141+
return req.State
142+
}
143+
144+
// lastError returns the LastError reported by the Status RPC (populated on the
145+
// error path).
146+
func (s *E2EIntegrationSuite) lastError(sqid string) string {
147+
t := s.T()
148+
resp, err := s.gatewayClient.Status(s.ctx, &gatewaypb.StatusRequest{Sqid: sqid})
149+
require.NoError(t, err, "Status failed for %s", sqid)
150+
return resp.LastError
151+
}
152+
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+
}
161+
}

0 commit comments

Comments
 (0)