Skip to content

Commit 59a47f1

Browse files
authored
feat(example): create, enqueue, and watch demo pull requests (#569)
## Summary ### Why? Exercising the demo meant opening pull requests by hand and pasting their URLs into a land command — slow enough to discourage running it, and it made the interesting case impossible to reach in practice. A queue that only ever holds one request in flight never batches, never analyzes a conflict against another batch, and never speculates. ### What? `make demo-pr` creates pull requests in the scratch repository, enqueues them, and watches them settle. Each pull request is enqueued the moment it is created, so the queue is already working on the first while the last is still being opened. Nothing is awaited until everything is in; the wait happens once, against all of them. That overlap is what puts requests in flight against each other, which is the whole point of running more than one. `STACKED=true` is the exception, and necessarily so: one request carries the whole chain, so it can only be submitted once every change in it exists. That is the atomic-stack path, where the set reaches the target in a single push. Progress is a table keyed on the gateway's status API, redrawn as requests move, so it is visible which stage each one is in rather than only whether it finished. On a terminal it redraws in place; piped to a file it prints a fresh table whenever something changes, so logs stay readable. It lives beside the configuration it drives, under `service/submitqueue/example/`, and reaches GitHub through the REST API rather than a clone — so it needs no checkout and no git binary, only `GITHUB_TOKEN`, the credential the stack already uses. ## Test Plan ✅ `bazel build //service/submitqueue/example/pr`; `make help` lists `demo-pr`. Running it end to end needs a scratch repository and a token, so it is exercised the same way the rest of the provider path is — manually. See `doc/howto/PROVIDER-E2E.md`. ## Also in this PR - **feat(demo): live table with the full state trail per request** - **feat(messagequeue): level the queue's own logs separately** - **docs(demo): correct the sample trail to states the pipeline emits** - **feat(demo): spread each change over a sharded file tree** <sub>Each commit above carries its own rationale and test plan in its message.</sub> ## Issues
1 parent 26ea819 commit 59a47f1

18 files changed

Lines changed: 2000 additions & 5 deletions

File tree

Makefile

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,13 @@ export REPO_ROOT := $(shell pwd)
5252
PROVIDER ?= github
5353
export SQ_PROVIDER_CONFIG_DIR ?= $(REPO_ROOT)/service/submitqueue/demo/provider/$(PROVIDER)
5454

55-
# Defaults for `make land` against the provider demo stack.
55+
# Defaults for `make land` / `make demo-pr` against the provider demo stack.
56+
DEMO_REPO ?= behinddwalls/sq-demo
57+
COUNT ?= 3
58+
FILES ?= 3
59+
STACKED ?= false
60+
LAND ?= true
61+
WATCH ?= true
5662
QUEUE ?= demo-queue
5763
STRATEGY ?= SQUASH_REBASE
5864
GATEWAY_ADDR ?= localhost:8081
@@ -147,6 +153,17 @@ clean-proto: ## Clean generated proto files
147153
@rm -f $(foreach p,$(PROTO_PACKAGES),$(p)/protopb/*.pb.go $(p)/protopb/*.pb.yarpc.go)
148154
@echo "Proto clean complete!"
149155

156+
demo-pr: ## Create N PRs in the demo repo, enqueue each as it is created, and watch (COUNT=3 FILES=3; needs GITHUB_TOKEN)
157+
@$(BAZEL) run //service/submitqueue/demo/pr -- \
158+
-repo $(DEMO_REPO) \
159+
-count $(COUNT) \
160+
-files $(FILES) \
161+
-stacked=$(STACKED) \
162+
-gateway $(GATEWAY_ADDR) \
163+
-queue $(QUEUE) \
164+
-strategy $(STRATEGY) \
165+
-land=$(LAND) -watch=$(WATCH)
166+
150167
deps: tidy-go ## Download and tidy Go dependencies
151168
@echo "Dependencies installed!"
152169

doc/howto/PROVIDER-E2E.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ For a **fine-grained** token, grant these repository permissions. Each is here b
2525
| Metadata | Read | mandatory on every fine-grained token; GitHub adds it for you |
2626
| Contents | Read and write | the git merger — clone, fetch, push to the target branch, and force-move each landed change's head branch |
2727
| Pull requests | Read | the change provider reads pull request metadata, and `land -pr` reads the head commit |
28-
| Pull requests | Read **and write** | only for `make demo-prs`, which opens pull requests |
28+
| Pull requests | Read **and write** | only for `make demo-pr`, which opens pull requests |
2929
| Actions | Read and write | only if you switch the build runner to GitHub Actions — dispatch a run, poll it, cancel it |
3030

3131
A **classic** PAT needs `repo`, plus `workflow` if you use the GitHub Actions build runner.
@@ -89,6 +89,44 @@ make land PRS="https://github.com/<you>/<repo>/pull/1 \
8989

9090
The order of `PRS` is the stack order. All three land as **one push** to `main` — there is no window where a reader sees the stack half-applied — and all three show as merged. Tier 2 asserts the single-push property mechanically, by counting ref updates in the target's reflog.
9191

92+
## Simulating traffic
93+
94+
Opening pull requests by hand gets old fast. `demo-pr` creates them, enqueues them, and shows you where each one is:
95+
96+
```bash
97+
make demo-pr # 3 independent PRs, each enqueued as it is created
98+
make demo-pr COUNT=8 # more traffic
99+
make demo-pr FILES=8 # wider changes, more files per PR
100+
make demo-pr STACKED=true # one stack, enqueued as a single request
101+
make demo-pr LAND=false # create only, print the land command
102+
```
103+
104+
Each pull request is enqueued the moment it exists, so the queue is already working on the first while the last is still being opened. That overlap is the point: a queue holding one request at a time never batches, never analyzes a conflict against another batch, and never speculates. Nothing is awaited until every request is in.
105+
106+
The table is there from the start — one row per land request, drawn before the first pull request exists and filled in as the run proceeds. Whatever is happening right now is a single line underneath it, so creating and enqueuing does not scroll the table away:
107+
108+
```
109+
REQUEST CHANGES ELAPSED STAGE
110+
───────────── ─────── ─────── ─────────────────────────────────────────────────
111+
demo-queue/12 #31 34s accepted → started → validated → batched → landed
112+
demo-queue/13 #32 31s accepted → started → validated → batched
113+
demo-queue/14 #33 28s accepted → started
114+
115+
▸ 1 of 3 settled
116+
```
117+
118+
Each row shows the states its request passed through, not just the one it is in. That comes from the gateway's history API rather than from sampling the current status, so a transition between two polls is not missed. `CHANGES` links to the pull request: on a terminal `#31` is clickable, and in a redirected run it is written out as a full URL instead. `ELAPSED` runs from the moment the gateway accepted the request and stops when it settles, so a finished row keeps the time it took rather than counting on.
119+
120+
The trail is only as detailed as what the pipeline reports, which today is `accepted`, `started`, `validated`, `batched` and then a terminal `landed`, `error` or `cancelled`. The finer-grained statuses the API defines — `speculating`, `building`, `landing` and the rest — are never published, so a request sits on `batched` for the whole of its active life even while its batch is speculating and building. Do not read that as the request being stuck.
121+
122+
`STACKED=true` is the exception to the overlap: one request carries the whole chain, so it can only go in once every pull request in it exists. That is the atomic-stack path — the whole set reaches `main` in a single push, and the table shows it as the single row it is.
123+
124+
It talks to GitHub over the REST API with the same `GITHUB_TOKEN`, so it needs no clone and no git binary. Each run tags its branches with a timestamp so repeated runs do not collide, and every file a change writes is at a path no other change uses, so independent changes do not conflict by accident.
125+
126+
A change touches several files rather than one, each committed separately, so it arrives as a multi-file, multi-commit pull request — closer to a real change, and enough to exercise replaying a range of commits. `FILES` sets the floor (default 3); the actual count varies a little above it, derived from the run tag so replaying a tag reproduces the same run. Paths are sharded into two levels of hex buckets under `demo/` (`demo/c2/91/<tag>-<change>-<file>.txt`), which keeps the tree from degenerating into one enormous directory as runs accumulate.
127+
128+
The command exits non-zero if any request settles anywhere other than `landed`, so it works in a script. Piped to a file it prints a fresh table whenever a request moves — and not when only the clock did — instead of redrawing in place.
129+
92130
## Watching it work
93131

94132
```bash
@@ -101,6 +139,14 @@ Runway logs each merge and each head-branch move:
101139
moved change head branch to its landed commit {"change": "you/repo#1", "branch": "refs/heads/feature-a", ...}
102140
```
103141

142+
The message queue logs a line per message published, fetched, leased and acked, which at debug level buries everything else a service says. It is levelled separately from the rest of the service, at info by default. To follow the queue itself — chasing a message that never arrived, or a partition that never got leased — turn it back up for the services you care about:
143+
144+
```bash
145+
QUEUE_LOG_LEVEL=debug make local-submitqueue-start
146+
```
147+
148+
`QUEUE_LOG_LEVEL` takes any zap level name. It can only raise the queue's level above the one the service logger was built with, never lower it, so it cannot be used to make a quiet service verbose.
149+
104150
## When it does not work
105151

106152
**The push is rejected on the first try.** Branch protection on `main` — required status checks, or a linear-history or no-force-push rule — applies to the merger like anyone else. Either relax it on the scratch repo or add the token's identity to the bypass list.

platform/extension/messagequeue/mysql/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ go_library(
2626
"@com_github_uber_go_tally//:go_default_library",
2727
"@org_uber_go_mock//gomock:go_default_library",
2828
"@org_uber_go_zap//:go_default_library",
29+
"@org_uber_go_zap//zapcore:go_default_library",
2930
],
3031
)
3132

platform/extension/messagequeue/mysql/sql.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222

2323
"github.com/uber-go/tally"
2424
"go.uber.org/zap"
25+
"go.uber.org/zap/zapcore"
2526

2627
extqueue "github.com/uber/submitqueue/platform/extension/messagequeue"
2728
)
@@ -40,6 +41,16 @@ type Params struct {
4041
// Logger for debugging and observability (required)
4142
Logger *zap.Logger
4243

44+
// LogLevel is the minimum level for the queue's own logs, as a zap level
45+
// name ("debug", "info", ...). Empty selects info.
46+
//
47+
// The queue logs a line per message published, fetched, leased and acked,
48+
// which at debug buries everything else a service says. Levelling it here
49+
// rather than at the service logger keeps the rest of that service's debug
50+
// output intact. The level can only be raised above the one the supplied
51+
// logger was built with, never lowered.
52+
LogLevel string
53+
4354
// MetricsScope for metrics collection (required)
4455
MetricsScope tally.Scope
4556

@@ -55,8 +66,17 @@ func NewQueue(params Params) (extqueue.Queue, error) {
5566
return nil, fmt.Errorf("failed to ping database: %w", err)
5667
}
5768

58-
logger := params.Logger.Sugar().Named("queue_mysql")
59-
logger.Infow("created SQL queue")
69+
level := zapcore.InfoLevel
70+
if params.LogLevel != "" {
71+
parsed, err := zapcore.ParseLevel(params.LogLevel)
72+
if err != nil {
73+
return nil, fmt.Errorf("invalid queue log level %q: %w", params.LogLevel, err)
74+
}
75+
level = parsed
76+
}
77+
78+
logger := params.Logger.WithOptions(zap.IncreaseLevel(level)).Sugar().Named("queue_mysql")
79+
logger.Infow("created SQL queue", "log_level", level.String())
6080

6181
// Create stores
6282
messageStore := newMessageStore(params.DB, logger, params.MetricsScope)

platform/extension/messagequeue/mysql/sql_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,44 @@ func TestNewQueue(t *testing.T) {
7070

7171
require.NoError(t, mock.ExpectationsWereMet())
7272
})
73+
t.Run("accepts a log level", func(t *testing.T) {
74+
db, mock, err := sqlmock.New(sqlmock.MonitorPingsOption(true))
75+
require.NoError(t, err)
76+
defer db.Close()
77+
78+
mock.ExpectPing()
79+
80+
q, err := NewQueue(Params{
81+
DB: db,
82+
Logger: zaptest.NewLogger(t),
83+
LogLevel: "debug",
84+
MetricsScope: tally.NewTestScope("test", nil),
85+
})
86+
87+
require.NoError(t, err)
88+
require.NotNil(t, q)
89+
assert.NoError(t, q.Close())
90+
91+
require.NoError(t, mock.ExpectationsWereMet())
92+
})
93+
94+
t.Run("error when the log level is not a level", func(t *testing.T) {
95+
db, mock, err := sqlmock.New(sqlmock.MonitorPingsOption(true))
96+
require.NoError(t, err)
97+
defer db.Close()
98+
99+
mock.ExpectPing()
100+
101+
q, err := NewQueue(Params{
102+
DB: db,
103+
Logger: zaptest.NewLogger(t),
104+
LogLevel: "loud",
105+
MetricsScope: tally.NewTestScope("test", nil),
106+
})
107+
108+
require.Error(t, err)
109+
assert.Nil(t, q)
110+
})
73111
}
74112

75113
func TestQueue_Publisher(t *testing.T) {

service/runway/server/docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ services:
4949
- MERGER=${SQ_RUNWAY_MERGER:-}
5050
# Queue infrastructure connection
5151
- QUEUE_MYSQL_DSN=root:root@tcp(mysql-queue:3306)/submitqueue?parseTime=true
52+
# Level for the queue's own logs; info by default so its per-message
53+
# chatter does not bury the rest of the service at debug.
54+
- QUEUE_LOG_LEVEL=${QUEUE_LOG_LEVEL:-}
5255
- HOSTNAME=runway-dev
5356
depends_on:
5457
mysql-queue:

service/runway/server/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ func run() error {
138138
mysqlQueue, err := queueMySQL.NewQueue(queueMySQL.Params{
139139
DB: queueDB,
140140
Logger: logger,
141+
LogLevel: os.Getenv("QUEUE_LOG_LEVEL"),
141142
MetricsScope: scope.SubScope("queue"),
142143
})
143144
if err != nil {

service/stovepipe/docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ services:
6767
- PORT=:8080
6868
- STORAGE_MYSQL_DSN=root:root@tcp(mysql-app:3306)/submitqueue?parseTime=true
6969
- QUEUE_MYSQL_DSN=root:root@tcp(mysql-queue:3306)/submitqueue?parseTime=true
70+
# Level for the queue's own logs; info by default so its per-message
71+
# chatter does not bury the rest of the service at debug.
72+
- QUEUE_LOG_LEVEL=${QUEUE_LOG_LEVEL:-}
7073
- HOSTNAME=stovepipe-dev
7174
depends_on:
7275
mysql-app:

service/stovepipe/server/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ func run() error {
241241
mysqlQueue, err := queueMySQL.NewQueue(queueMySQL.Params{
242242
DB: queueDB,
243243
Logger: logger,
244+
LogLevel: os.Getenv("QUEUE_LOG_LEVEL"),
244245
MetricsScope: scope.SubScope("queue"),
245246
})
246247
if err != nil {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
load("@rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["main.go"],
6+
importpath = "github.com/uber/submitqueue/service/submitqueue/demo/pr",
7+
visibility = ["//visibility:private"],
8+
deps = [
9+
"//api/base/change/protopb:go_default_library",
10+
"//api/base/mergestrategy/protopb:go_default_library",
11+
"//api/submitqueue/gateway/protopb:go_default_library",
12+
"//platform/base/change/github:go_default_library",
13+
"//submitqueue/entity:go_default_library",
14+
"@org_golang_google_grpc//:go_default_library",
15+
"@org_golang_google_grpc//credentials/insecure:go_default_library",
16+
],
17+
)
18+
19+
go_binary(
20+
name = "pr",
21+
embed = [":go_default_library"],
22+
visibility = ["//visibility:public"],
23+
)
24+
25+
go_test(
26+
name = "go_default_test",
27+
srcs = ["main_test.go"],
28+
embed = [":go_default_library"],
29+
deps = [
30+
"//api/submitqueue/gateway/protopb:go_default_library",
31+
"@com_github_stretchr_testify//assert:go_default_library",
32+
"@com_github_stretchr_testify//require:go_default_library",
33+
"@org_golang_google_grpc//:go_default_library",
34+
],
35+
)

0 commit comments

Comments
 (0)