Skip to content

Commit ce86652

Browse files
committed
refactor(buildrunner): trigger on batches, resolve changes internally
Change BuildRunner.Trigger to take batch identity — base []entity.Batch (the dependency batches) and head entity.Batch (the batch under test) — instead of controller-pre-resolved base/head []entity.Change, per the extension contract. Each implementation (buildkite, githubactions, fake) gains an injected changeset.Resolver and resolves the base and head batches' changes itself; the build controller drops its private collectChanges walk and loads the dependency batches as identity. Status, Cancel, and the build id/status outputs are unchanged. The wiring injects the resolver into the fake build runner; the buildkite/githubactions Params gain a Resolver field. Revises build-runner.md, which had deliberately kept batches out of the boundary — the base/head split survives, expressed as batch identity.
1 parent 29f0bad commit ce86652

16 files changed

Lines changed: 209 additions & 123 deletions

File tree

doc/rfc/submitqueue/build-runner.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,23 +46,23 @@ See `extension/buildrunner/build_runner.go` for the exact Go signatures. The sec
4646

4747
### Trigger: base + head
4848

49-
`Trigger` takes two ordered lists of changes and a free-form metadata map:
49+
> **Revised by [extension-contract.md](extension-contract.md).** `Trigger` now takes identity at the batch granularity — `base []entity.Batch` (the dependency batches) and `head entity.Batch` (the batch under test) — and the runner resolves each batch's changes itself through an injected `changeset.Resolver`. The base/head split below still holds; only the boundary type changed from resolved `[]entity.Change` to batch identity. The "rejected: list-of-lists of changes" note is superseded by that RFC's "identity in, resolve internally" principle.
5050
51-
- **`base`** — changes from the dependency batches (assumed-good prefix). Ordered.
52-
- **`head`** — changes from the batch being verified. Ordered.
51+
`Trigger` takes the base dependency batches, the head batch, and a free-form metadata map:
52+
53+
- **`base`** — the dependency batches (assumed-good prefix), ordered. The runner resolves their changes.
54+
- **`head`** — the batch being verified. The runner resolves its changes.
5355
- **`metadata`** — caller-supplied attributes (requester, ticket ID, trace ID, etc.) the provider MAY persist or echo back via `Status`. Schema is caller/provider-defined; the interface treats it as opaque. `nil` is equivalent to an empty map.
5456

55-
The provider applies `base` then `head` in order on top of the queue's target branch and validates the resulting tree. Validation is **implicit and holistic**: it is not a per-change action, it is what the provider does after applying everything.
57+
The provider resolves and applies `base` then `head` in order on top of the queue's target branch and validates the resulting tree. Validation is **implicit and holistic**: it is not a per-change action, it is what the provider does after applying everything.
5658

5759
Why split base and head:
5860

5961
- The orchestrator's internal model already distinguishes them — a speculation path has a head batch and a list of base batches assumed to pass.
6062
- Lets a provider cache or short-circuit the base when it has validated the same prefix before — a hot path for stacked-batch speculation.
6163
- Lets the provider attribute terminal failure to base vs head in `BuildMetadata` without the orchestrator having to round-trip the split itself.
6264

63-
Rejected: a single flat `changes []entity.Change`. Provider would have to deduce base via prefix matching and could not distinguish "base broke" from "head broke" without out-of-band hints. Loses the orchestrator's clearest piece of structural information at the boundary for no gain.
64-
65-
Rejected: list-of-lists, one slice per batch. Pushes batch structure across the boundary, which the provider does not care about. The provider thinks in terms of "stuff to apply before" and "stuff to validate" — base / head matches that mental model. Batches are an orchestrator concept.
65+
Rejected: a single flat input with no base/head split. Provider would have to deduce base via prefix matching and could not distinguish "base broke" from "head broke" without out-of-band hints. Loses the orchestrator's clearest piece of structural information at the boundary for no gain.
6666

6767
### Async vs sync contract
6868

example/submitqueue/orchestrator/server/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -833,7 +833,7 @@ func newQueueRegistry(logger *zap.Logger, scope tally.Scope, resolver changeset.
833833
mergeChecker: mc,
834834
changeProvider: cp,
835835
pusher: psh,
836-
buildRunner: buildfake.New(),
836+
buildRunner: buildfake.New(resolver),
837837
scorer: scorerfake.New(resolver, heuristic.New(
838838
resolver,
839839
[]heuristic.Bucket{{Min: 0, Max: 1<<31 - 1, Score: 0.5}},

submitqueue/extension/buildrunner/build_runner.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ type BuildRunner interface {
4040
// Validation is implicit and holistic — it is what the runner does
4141
// after applying everything, not a per-change action.
4242
//
43-
// base contains changes from the dependency batches (an assumed-good
44-
// prefix). head contains changes from the batch being verified.
45-
// Splitting them lets a runner cache or short-circuit the base when
46-
// it has validated the same prefix before, and lets it attribute
43+
// base is the dependency batches (an assumed-good prefix); head is the
44+
// batch being verified. The runner resolves each batch's changes itself
45+
// through an injected changeset resolver. Keeping base and head as
46+
// separate batch inputs lets a runner cache or short-circuit the base
47+
// when it has validated the same prefix before, and lets it attribute
4748
// terminal failure to base vs head in BuildMetadata.
4849
//
4950
// metadata carries free-form caller-supplied attributes (e.g. requester,
@@ -59,8 +60,8 @@ type BuildRunner interface {
5960
// Factory that built it. Returns an error if the request is invalid.
6061
Trigger(
6162
ctx context.Context,
62-
base []entity.Change,
63-
head []entity.Change,
63+
base []entity.Batch,
64+
head entity.Batch,
6465
metadata entity.BuildMetadata,
6566
) (buildID entity.BuildID, err error)
6667

submitqueue/extension/buildrunner/buildkite/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ go_library(
99
importpath = "github.com/uber/submitqueue/submitqueue/extension/buildrunner/buildkite",
1010
visibility = ["//visibility:public"],
1111
deps = [
12+
"//submitqueue/core/changeset",
1213
"//submitqueue/entity",
1314
"//submitqueue/extension/buildrunner",
1415
"@org_uber_go_zap//:zap",
@@ -21,6 +22,8 @@ go_test(
2122
embed = [":buildkite"],
2223
deps = [
2324
"//core/httpclient",
25+
"//submitqueue/core/changeset",
26+
"//submitqueue/core/changeset/fake",
2427
"//submitqueue/entity",
2528
"//submitqueue/extension/buildrunner",
2629
"@com_github_stretchr_testify//assert",

submitqueue/extension/buildrunner/buildkite/buildkite.go

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838

3939
"go.uber.org/zap"
4040

41+
"github.com/uber/submitqueue/submitqueue/core/changeset"
4142
"github.com/uber/submitqueue/submitqueue/entity"
4243
"github.com/uber/submitqueue/submitqueue/extension/buildrunner"
4344
)
@@ -66,9 +67,10 @@ const (
6667

6768
// runner implements buildrunner.BuildRunner.
6869
type runner struct {
69-
cfg buildrunner.Config
70-
client *client
71-
logger *zap.SugaredLogger
70+
cfg buildrunner.Config
71+
client *client
72+
resolver changeset.Resolver
73+
logger *zap.SugaredLogger
7274
}
7375

7476
var _ buildrunner.BuildRunner = (*runner)(nil)
@@ -83,6 +85,8 @@ type Params struct {
8385
// for the base URL (via httpclient.BaseURLTransport) and auth (via a
8486
// transport layer). If nil, http.DefaultClient is used.
8587
HTTPClient *http.Client
88+
// Resolver resolves a batch's changes (base and head batches).
89+
Resolver changeset.Resolver
8690
// Logger is the structured logger.
8791
Logger *zap.SugaredLogger
8892
}
@@ -100,24 +104,34 @@ func NewBuildRunner(params Params) (buildrunner.BuildRunner, error) {
100104
if params.Logger == nil {
101105
return nil, fmt.Errorf("logger is required")
102106
}
103-
return newRunner(params.Config, &client{httpClient: params.HTTPClient}, params.Logger.Named("buildkite_buildrunner")), nil
107+
return newRunner(params.Config, &client{httpClient: params.HTTPClient}, params.Resolver, params.Logger.Named("buildkite_buildrunner")), nil
104108
}
105109

106110
// newRunner constructs a runner. Used by NewBuildRunner and by tests.
107-
func newRunner(cfg buildrunner.Config, c *client, logger *zap.SugaredLogger) *runner {
111+
func newRunner(cfg buildrunner.Config, c *client, resolver changeset.Resolver, logger *zap.SugaredLogger) *runner {
108112
return &runner{
109-
cfg: cfg,
110-
client: c,
111-
logger: logger,
113+
cfg: cfg,
114+
client: c,
115+
resolver: resolver,
116+
logger: logger,
112117
}
113118
}
114119

115120
// Trigger calls the Buildkite API to create the build and returns the Buildkite
116121
// build number as the build ID. Errors are propagated to the caller so the
117122
// queue consumer can nack and retry.
118-
func (r *runner) Trigger(ctx context.Context, base, head []entity.Change, metadata entity.BuildMetadata) (entity.BuildID, error) {
119-
baseJSON, _ := json.Marshal(flattenURIs(base))
120-
headJSON, _ := json.Marshal(flattenURIs(head))
123+
func (r *runner) Trigger(ctx context.Context, base []entity.Batch, head entity.Batch, metadata entity.BuildMetadata) (entity.BuildID, error) {
124+
baseChanges, err := resolveBatches(ctx, r.resolver, base)
125+
if err != nil {
126+
return entity.BuildID{}, fmt.Errorf("buildkite: resolve base: %w", err)
127+
}
128+
headChanges, err := r.resolver.ChangesForBatch(ctx, head)
129+
if err != nil {
130+
return entity.BuildID{}, fmt.Errorf("buildkite: resolve head: %w", err)
131+
}
132+
133+
baseJSON, _ := json.Marshal(flattenURIs(baseChanges))
134+
headJSON, _ := json.Marshal(flattenURIs(headChanges))
121135

122136
env := map[string]string{
123137
EnvKeyBaseURIs: string(baseJSON),
@@ -189,6 +203,19 @@ func flattenURIs(changes []entity.Change) []string {
189203
return uris
190204
}
191205

206+
// resolveBatches resolves each batch's changes and concatenates them in order.
207+
func resolveBatches(ctx context.Context, resolver changeset.Resolver, batches []entity.Batch) ([]entity.Change, error) {
208+
var changes []entity.Change
209+
for _, b := range batches {
210+
cs, err := resolver.ChangesForBatch(ctx, b)
211+
if err != nil {
212+
return nil, err
213+
}
214+
changes = append(changes, cs...)
215+
}
216+
return changes, nil
217+
}
218+
192219
// encodeBuildNumber encodes a Buildkite build number as the SQ build ID.
193220
func encodeBuildNumber(number int) string {
194221
return strconv.Itoa(number)

submitqueue/extension/buildrunner/buildkite/buildkite_test.go

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,29 @@ import (
2727
"github.com/stretchr/testify/assert"
2828
"github.com/stretchr/testify/require"
2929
"github.com/uber/submitqueue/core/httpclient"
30+
"github.com/uber/submitqueue/submitqueue/core/changeset"
31+
changesetfake "github.com/uber/submitqueue/submitqueue/core/changeset/fake"
3032
"github.com/uber/submitqueue/submitqueue/entity"
3133
"github.com/uber/submitqueue/submitqueue/extension/buildrunner"
3234
)
3335

34-
// newTestRunner creates a runner backed by a test HTTP server.
35-
func newTestRunner(t *testing.T, handler http.Handler) *runner {
36+
// newTestRunner creates a runner backed by a test HTTP server. An optional
37+
// resolver seeds the batch changes the runner resolves; omit it for tests that
38+
// do not trigger builds (Status/Cancel).
39+
func newTestRunner(t *testing.T, handler http.Handler, resolver ...changeset.Resolver) *runner {
3640
t.Helper()
3741
srv := httptest.NewServer(handler)
3842
t.Cleanup(srv.Close)
3943
c, err := httpclient.NewClient(srv.URL)
4044
require.NoError(t, err)
45+
r := changeset.Resolver(changesetfake.New())
46+
if len(resolver) > 0 {
47+
r = resolver[0]
48+
}
4149
return newRunner(
4250
buildrunner.Config{QueueName: "my-queue"},
4351
&client{httpClient: c},
52+
r,
4453
zap.NewNop().Sugar(),
4554
)
4655
}
@@ -70,17 +79,18 @@ func TestTrigger_SubmitsCorrectPayloadAndReturnsBuildkiteNumber(t *testing.T) {
7079
var capturedMethod string
7180
var capturedBody []byte
7281

82+
resolver := changesetfake.New().
83+
Set("base-batch", entity.Change{URIs: []string{"github://org/repo/pull/1/aaa111"}}).
84+
Set("head-batch", entity.Change{URIs: []string{"github://org/repo/pull/2/bbb222"}})
85+
7386
r := newTestRunner(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
7487
capturedMethod = req.Method
7588
capturedBody, _ = io.ReadAll(req.Body)
7689
w.Header().Set("Content-Type", "application/json")
7790
_, _ = w.Write(buildJSON(42, "scheduled", "https://buildkite.com/test-org/my-pipeline/builds/42"))
78-
}))
91+
}), resolver)
7992

80-
base := []entity.Change{{URIs: []string{"github://org/repo/pull/1/aaa111"}}}
81-
head := []entity.Change{{URIs: []string{"github://org/repo/pull/2/bbb222"}}}
82-
83-
id, err := r.Trigger(context.Background(), base, head, nil)
93+
id, err := r.Trigger(context.Background(), []entity.Batch{{ID: "base-batch"}}, entity.Batch{ID: "head-batch"}, nil)
8494
require.NoError(t, err)
8595
assert.Equal(t, encodeBuildNumber(42), id.ID)
8696

@@ -95,13 +105,14 @@ func TestTrigger_SubmitsCorrectPayloadAndReturnsBuildkiteNumber(t *testing.T) {
95105

96106
func TestTrigger_EmptyBase_ProducesJSONArray(t *testing.T) {
97107
var capturedBody []byte
108+
resolver := changesetfake.New().Set("head-batch", entity.Change{URIs: []string{"u"}})
98109
r := newTestRunner(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
99110
capturedBody, _ = io.ReadAll(req.Body)
100111
w.Header().Set("Content-Type", "application/json")
101112
_, _ = w.Write(buildJSON(1, "scheduled", ""))
102-
}))
113+
}), resolver)
103114

104-
_, err := r.Trigger(context.Background(), nil, []entity.Change{{URIs: []string{"u"}}}, nil)
115+
_, err := r.Trigger(context.Background(), nil, entity.Batch{ID: "head-batch"}, nil)
105116
require.NoError(t, err)
106117

107118
var req createBuildRequest
@@ -112,17 +123,17 @@ func TestTrigger_EmptyBase_ProducesJSONArray(t *testing.T) {
112123

113124
func TestTrigger_MultipleChangesFlattened(t *testing.T) {
114125
var capturedBody []byte
126+
resolver := changesetfake.New().Set("head-batch",
127+
entity.Change{URIs: []string{"github://org/repo/pull/1/aaa"}},
128+
entity.Change{URIs: []string{"github://org/repo/pull/2/bbb", "github://org/repo/pull/3/ccc"}},
129+
)
115130
r := newTestRunner(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
116131
capturedBody, _ = io.ReadAll(req.Body)
117132
w.Header().Set("Content-Type", "application/json")
118133
_, _ = w.Write(buildJSON(2, "scheduled", ""))
119-
}))
134+
}), resolver)
120135

121-
head := []entity.Change{
122-
{URIs: []string{"github://org/repo/pull/1/aaa"}},
123-
{URIs: []string{"github://org/repo/pull/2/bbb", "github://org/repo/pull/3/ccc"}},
124-
}
125-
_, err := r.Trigger(context.Background(), nil, head, nil)
136+
_, err := r.Trigger(context.Background(), nil, entity.Batch{ID: "head-batch"}, nil)
126137
require.NoError(t, err)
127138

128139
var req createBuildRequest
@@ -138,20 +149,21 @@ func TestTrigger_BuildkiteError_ReturnsError(t *testing.T) {
138149
w.WriteHeader(http.StatusInternalServerError)
139150
}))
140151

141-
_, err := r.Trigger(context.Background(), nil, nil, nil)
152+
_, err := r.Trigger(context.Background(), nil, entity.Batch{ID: "head-batch"}, nil)
142153
require.Error(t, err)
143154
}
144155

145156
func TestTrigger_WithMetadata_SetsEnvVar(t *testing.T) {
146157
var capturedBody []byte
158+
resolver := changesetfake.New().Set("head-batch", entity.Change{URIs: []string{"u"}})
147159
r := newTestRunner(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
148160
capturedBody, _ = io.ReadAll(req.Body)
149161
w.Header().Set("Content-Type", "application/json")
150162
_, _ = w.Write(buildJSON(10, "scheduled", ""))
151-
}))
163+
}), resolver)
152164

153165
metadata := entity.BuildMetadata{"requester": "alice", "ticket": "SQ-42"}
154-
_, err := r.Trigger(context.Background(), nil, []entity.Change{{URIs: []string{"u"}}}, metadata)
166+
_, err := r.Trigger(context.Background(), nil, entity.Batch{ID: "head-batch"}, metadata)
155167
require.NoError(t, err)
156168

157169
var req createBuildRequest
@@ -165,13 +177,14 @@ func TestTrigger_WithMetadata_SetsEnvVar(t *testing.T) {
165177

166178
func TestTrigger_NilMetadata_NoMetadataEnvVar(t *testing.T) {
167179
var capturedBody []byte
180+
resolver := changesetfake.New().Set("head-batch", entity.Change{URIs: []string{"u"}})
168181
r := newTestRunner(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
169182
capturedBody, _ = io.ReadAll(req.Body)
170183
w.Header().Set("Content-Type", "application/json")
171184
_, _ = w.Write(buildJSON(11, "scheduled", ""))
172-
}))
185+
}), resolver)
173186

174-
_, err := r.Trigger(context.Background(), nil, []entity.Change{{URIs: []string{"u"}}}, nil)
187+
_, err := r.Trigger(context.Background(), nil, entity.Batch{ID: "head-batch"}, nil)
175188
require.NoError(t, err)
176189

177190
var req createBuildRequest

submitqueue/extension/buildrunner/fake/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ go_library(
66
importpath = "github.com/uber/submitqueue/submitqueue/extension/buildrunner/fake",
77
visibility = ["//visibility:public"],
88
deps = [
9+
"//submitqueue/core/changeset",
910
"//submitqueue/core/fakemarker",
1011
"//submitqueue/entity",
1112
"//submitqueue/extension/buildrunner",
@@ -17,6 +18,7 @@ go_test(
1718
srcs = ["fake_test.go"],
1819
embed = [":fake"],
1920
deps = [
21+
"//submitqueue/core/changeset/fake",
2022
"//submitqueue/entity",
2123
"//submitqueue/extension/buildrunner",
2224
"@com_github_stretchr_testify//assert",

submitqueue/extension/buildrunner/fake/fake.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"fmt"
3838
"strings"
3939

40+
"github.com/uber/submitqueue/submitqueue/core/changeset"
4041
"github.com/uber/submitqueue/submitqueue/core/fakemarker"
4142
"github.com/uber/submitqueue/submitqueue/entity"
4243
"github.com/uber/submitqueue/submitqueue/extension/buildrunner"
@@ -57,21 +58,28 @@ const outcomeOK = "ok"
5758
// per-build state: the outcome is encoded in the BuildID at Trigger and read
5859
// back out at Status. Uniqueness comes from a random suffix per ID, so it needs
5960
// no shared counter and never collides across instances or processes.
60-
type runner struct{}
61+
type runner struct {
62+
resolver changeset.Resolver
63+
}
6164

6265
// New returns a buildrunner.BuildRunner that defaults to succeeding and honors
63-
// marker tokens embedded in head change URIs.
64-
func New() buildrunner.BuildRunner {
65-
return &runner{}
66+
// marker tokens embedded in head change URIs. The resolver resolves the head
67+
// batch's changes so the marker can be inspected.
68+
func New(resolver changeset.Resolver) buildrunner.BuildRunner {
69+
return &runner{resolver: resolver}
6670
}
6771

6872
// Trigger fails when a head change URI carries the trigger-error marker;
6973
// otherwise it returns a unique BuildID that encodes the terminal outcome the
7074
// build should report at Status time (decided from the head marker). The base
71-
// changes and metadata are ignored.
72-
func (r *runner) Trigger(_ context.Context, _ []entity.Change, head []entity.Change, _ entity.BuildMetadata) (entity.BuildID, error) {
75+
// batches and metadata are ignored.
76+
func (r *runner) Trigger(ctx context.Context, _ []entity.Batch, head entity.Batch, _ entity.BuildMetadata) (entity.BuildID, error) {
77+
headChanges, err := r.resolver.ChangesForBatch(ctx, head)
78+
if err != nil {
79+
return entity.BuildID{}, fmt.Errorf("fake: resolve head: %w", err)
80+
}
7381
outcome := outcomeOK
74-
switch fakemarker.TokenInChanges(head) {
82+
switch fakemarker.TokenInChanges(headChanges) {
7583
case tokenTriggerError:
7684
return entity.BuildID{}, fmt.Errorf("fake: marked trigger error")
7785
case tokenFail:

0 commit comments

Comments
 (0)