Skip to content

Commit a0d6532

Browse files
authored
fix(extension): forward the per-queue Config down to every impl (#553)
## Summary ### Why? Per-queue extensions are resolved through a `Factory` contract — `For(cfg Config) (Impl, error)`, where `Config` carries the queue name. The controllers hold up their end: `build.go:233` and `buildsignal.go:183` both call `c.buildRunners.For(buildrunner.Config{QueueName: batch.Queue})`. The wiring layer dropped it. In `service/submitqueue/orchestrator/server/profiles.go`, `Profile` held built extension **instances** for ChangeProvider, BuildRunner, Analyzer, Scorer and Speculator. Only `Storage` held a `Factory`. So the two ends of the seam looked like this: ```go return p.For(c.QueueName).Storage.For(c) // forwards cfg onward return p.For(c.QueueName).BuildRunner, nil // cfg is a map key, then dropped ``` An implementation's queue name was therefore fixed at construction. For queues listed in `byQueue` you could hand-copy the name into each profile. For `defaultProfile` you could not: one instance serves every queue without an explicit entry, so it can only ever carry one queue name — or none. The visible symptom is the Buildkite and GitHub Actions build runners, which read `cfg.QueueName` to emit `SQ_QUEUE` / `sq_queue` for the pipeline script. Both have zero non-test callers, so nothing ever exercised the path and nothing caught the gap. ### What? **The seam.** Every `Profile` field is now a `Factory`, and each `XFactory()` resolves the profile by name and then forwards the whole `Config` into it — the same second `For(c)` that `Storage` already used. Implementations are built per resolution, which is what `counterFactory.For` already does for the MySQL counter; anything expensive and queue-independent (the resolver, the metrics scope, the change provider's HTTP clients) is still built once in `newProfiles`. `newChangeProvider` becomes `newChangeProviderFactory`, splitting HTTP client construction from the per-queue provider that wraps it. `withSpeculator` becomes a lazy closure that resolves the profile's scorer at the queue the speculator itself was asked for, so the fix reaches one level down. That introduces a real error path where a struct field read could not fail; it is propagated rather than yielding a speculator over a nil scorer. `ScorerFactory()` is added for symmetry — the scorer was previously reachable only through `withSpeculator`. **The implementations.** Every impl belonging to an extension that has a `Config`+`Factory` pair now receives it: the conflict analyzers, the scorers, the validators, the change providers, the merge checkers, the fake build runners, the standard speculator, Stovepipe's two fakes, and Runway's `noop` and `fake` mergers. Impls with an existing `Params` struct gain a `Config` field, matching the Buildkite precedent; the rest take `cfg` as a leading positional parameter. Three decisions worth a reviewer's attention: - **`bestfirst` and `sticky` are untouched.** Only `speculator` declares a `Config` and a `Factory`; `generator` and `allocator` declare neither. They are composition internals rather than per-queue seams, so giving them a `Config` would have meant inventing two types that nothing reads. - **Runway's mergers are stateful by design.** `fakeMergerFactory` shared one instance so its `atomic.Uint64` revision-id counter stayed unique, and `gitMergerFactory` shares one checkout. Building one per queue would have broken both. The counter moves to the host factory and is injected, which keeps ids unique process-wide while letting each merger carry its own queue identity. This also fixes an existing quirk in `noopMergerFactory`, which already built a fresh merger — and therefore a fresh counter — on every resolution. `gitMergerFactory` still shares one instance and is now the only merger factory that does not forward its `Config`, with a comment recording why. - **`validatorfake.NewFactory` moves into the wiring.** `CLAUDE.md` reserves extension packages for contracts and implementations — deciding which impl serves which queue is host policy — so it is replaced by a `validatorFactory` adapter in the orchestrator wiring. Four implementations deliberately still take no `Config`: `submitqueue`, `stovepipe` and `platform`'s MySQL storage/counter backends already take the queue name as a plain string and the host adapters bridge to it, and `merger/git` is the shared-checkout exception above. The change is split into five commits — four mechanical per-family ones, then the wiring rewrite that actually fixes the bug — so it can be reviewed and bisected a family at a time. ## Test Plan ✅ `make test` — 96/96, including three new cases in `profiles_test.go` ✅ `make build` — all four service binaries ✅ `bazel test //test/integration/...` — 8/8 ✅ `bazel test //test/e2e/...` — 3/3 ✅ `make lint`, `make check-gazelle`, `make check-tidy`, `make check-mocks` — all clean Mutation-checked the regression guard: reverting a single `For(c)` back to `For(Config{})` fails `TestProfilesForwardQueueNameToFactories` for both the listed-queue and default-profile cases. That test is the thing that was missing — it asserts the `Config` survives the profile lookup, which is precisely what no test covered before. The implementation half was already covered: `buildkite_test.go:109` and `githubactions_test.go:123` assert the queue name reaches the wire. Together the two halves cover the full path from controller to CI request. Two pre-existing issues left untouched, both unrelated to this change: `go test ./runway/extension/merger/git/...` fails outside Bazel on a machine without a hermetic git toolchain (it passes under `bazel test`), and `go vet` reports `copylocks` on protobuf `MessageState` in the mergesignal tests.
1 parent 9a1cafa commit a0d6532

51 files changed

Lines changed: 648 additions & 268 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

runway/extension/merger/fake/fake.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,21 @@ var _ merger.Merger = (*Merger)(nil)
5151
// Merger is a Merger that succeeds unless a marker token in a change URI
5252
// requests otherwise.
5353
type Merger struct {
54-
seq atomic.Uint64
54+
// cfg is the per-queue identity this merger was built for.
55+
cfg merger.Config
56+
// seq mints the synthetic revision ids returned by Merge. It is supplied by
57+
// the caller so a factory that builds one Merger per queue can still hand
58+
// out ids that are unique across every queue in the process.
59+
seq *atomic.Uint64
5560
}
5661

57-
// New returns a Merger that defaults to success and honors marker tokens
58-
// embedded in change URIs.
59-
func New() *Merger { return &Merger{} }
62+
// New returns a Merger bound to the queue named in cfg that defaults to success
63+
// and honors marker tokens embedded in change URIs. seq must be non-nil and is
64+
// shared, not owned: callers minting ids from more than one Merger must pass the
65+
// same counter to each.
66+
func New(cfg merger.Config, seq *atomic.Uint64) *Merger {
67+
return &Merger{cfg: cfg, seq: seq}
68+
}
6069

6170
// CheckMergeability reports the request as mergeable unless a recognized marker
6271
// token asks for a failure. Outputs are empty, as for any dry run.

runway/extension/merger/fake/fake_test.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package fake
1616

1717
import (
1818
"context"
19+
"sync/atomic"
1920
"testing"
2021

2122
"github.com/stretchr/testify/assert"
@@ -27,6 +28,9 @@ import (
2728
"github.com/uber/submitqueue/runway/extension/merger"
2829
)
2930

31+
// testCfg is the per-queue identity used by every case in this file.
32+
var testCfg = merger.Config{QueueName: "test-queue"}
33+
3034
const baseURI = "github://github.example.com/uber/repo/pull/1/abcdef0123456789abcdef0123456789abcdef01"
3135

3236
// requestWith builds a two-step request whose second step carries the given
@@ -54,7 +58,7 @@ func TestUnmarkedRequestSucceeds(t *testing.T) {
5458
req := requestWith(baseURI)
5559

5660
t.Run("check mergeability reports no outputs", func(t *testing.T) {
57-
res, err := New().CheckMergeability(context.Background(), req)
61+
res, err := New(testCfg, new(atomic.Uint64)).CheckMergeability(context.Background(), req)
5862
require.NoError(t, err)
5963

6064
assert.Equal(t, req.GetId(), res.GetId())
@@ -67,7 +71,7 @@ func TestUnmarkedRequestSucceeds(t *testing.T) {
6771
})
6872

6973
t.Run("merge reports one output per step", func(t *testing.T) {
70-
res, err := New().Merge(context.Background(), req)
74+
res, err := New(testCfg, new(atomic.Uint64)).Merge(context.Background(), req)
7175
require.NoError(t, err)
7276

7377
assert.Equal(t, req.GetId(), res.GetId())
@@ -102,10 +106,10 @@ func TestMarkedRequestFails(t *testing.T) {
102106
fn func(*runwaymq.MergeRequest) (*runwaymq.MergeResult, error)
103107
}{
104108
{"CheckMergeability", func(r *runwaymq.MergeRequest) (*runwaymq.MergeResult, error) {
105-
return New().CheckMergeability(context.Background(), r)
109+
return New(testCfg, new(atomic.Uint64)).CheckMergeability(context.Background(), r)
106110
}},
107111
{"Merge", func(r *runwaymq.MergeRequest) (*runwaymq.MergeResult, error) {
108-
return New().Merge(context.Background(), r)
112+
return New(testCfg, new(atomic.Uint64)).Merge(context.Background(), r)
109113
}},
110114
} {
111115
t.Run(call.name, func(t *testing.T) {
@@ -129,7 +133,7 @@ func TestMarkedRequestFails(t *testing.T) {
129133
func TestUnrecognizedTokenSucceeds(t *testing.T) {
130134
req := requestWith(baseURI + "?sq-fake=some-other-fakes-token")
131135

132-
res, err := New().Merge(context.Background(), req)
136+
res, err := New(testCfg, new(atomic.Uint64)).Merge(context.Background(), req)
133137
require.NoError(t, err)
134138
assert.Equal(t, runwaypb.Outcome_SUCCEEDED, res.GetOutcome())
135139
}
@@ -144,7 +148,7 @@ func TestFirstRecognizedTokenWins(t *testing.T) {
144148
},
145149
}
146150

147-
_, err := New().Merge(context.Background(), req)
151+
_, err := New(testCfg, new(atomic.Uint64)).Merge(context.Background(), req)
148152
require.Error(t, err)
149153
assert.ErrorIs(t, err, merger.ErrConflict)
150154
assert.NotErrorIs(t, err, merger.ErrInvalidRequest)

runway/extension/merger/noop/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ go_test(
2121
"//api/base/mergestrategy/protopb:go_default_library",
2222
"//api/runway/messagequeue:go_default_library",
2323
"//api/runway/messagequeue/protopb:go_default_library",
24+
"//runway/extension/merger:go_default_library",
2425
"@com_github_stretchr_testify//assert:go_default_library",
2526
"@com_github_stretchr_testify//require:go_default_library",
2627
],

runway/extension/merger/noop/noop.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,20 @@ var _ merger.Merger = (*Merger)(nil)
3131

3232
// Merger is a no-op implementation that always succeeds.
3333
type Merger struct {
34-
seq atomic.Uint64
34+
// cfg is the per-queue identity this merger was built for.
35+
cfg merger.Config
36+
// seq mints the synthetic revision ids returned by Merge. It is supplied by
37+
// the caller so a factory that builds one Merger per queue can still hand
38+
// out ids that are unique across every queue in the process.
39+
seq *atomic.Uint64
3540
}
3641

37-
// New returns a new no-op Merger instance.
38-
func New() *Merger { return &Merger{} }
42+
// New returns a no-op Merger bound to the queue named in cfg. seq must be
43+
// non-nil and is shared, not owned: callers minting ids from more than one
44+
// Merger must pass the same counter to each.
45+
func New(cfg merger.Config, seq *atomic.Uint64) *Merger {
46+
return &Merger{cfg: cfg, seq: seq}
47+
}
3948

4049
func (v *Merger) CheckMergeability(_ context.Context, req *runwaymq.MergeRequest) (*runwaymq.MergeResult, error) {
4150
steps := make([]*runwaymq.StepResult, len(req.GetSteps()))

runway/extension/merger/noop/noop_test.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ package noop
1616

1717
import (
1818
"context"
19+
"sync/atomic"
1920
"testing"
2021

22+
"github.com/uber/submitqueue/runway/extension/merger"
23+
2124
"github.com/stretchr/testify/assert"
2225
"github.com/stretchr/testify/require"
2326
changepb "github.com/uber/submitqueue/api/base/change/protopb"
@@ -26,6 +29,9 @@ import (
2629
runwaypb "github.com/uber/submitqueue/api/runway/messagequeue/protopb"
2730
)
2831

32+
// testCfg is the per-queue identity used by every case in this file.
33+
var testCfg = merger.Config{QueueName: "test-queue"}
34+
2935
func testRequest() *runwaymq.MergeRequest {
3036
return &runwaymq.MergeRequest{
3137
Id: "queue-a/42",
@@ -46,7 +52,7 @@ func testRequest() *runwaymq.MergeRequest {
4652
}
4753

4854
func TestCheckMergeability(t *testing.T) {
49-
v := New()
55+
v := New(testCfg, new(atomic.Uint64))
5056
req := testRequest()
5157

5258
res, err := v.CheckMergeability(context.Background(), req)
@@ -62,7 +68,7 @@ func TestCheckMergeability(t *testing.T) {
6268
}
6369

6470
func TestMerge(t *testing.T) {
65-
v := New()
71+
v := New(testCfg, new(atomic.Uint64))
6672
req := testRequest()
6773

6874
res, err := v.Merge(context.Background(), req)
@@ -80,7 +86,7 @@ func TestMerge(t *testing.T) {
8086
}
8187

8288
func TestMerge_UniqueOutputIDs(t *testing.T) {
83-
v := New()
89+
v := New(testCfg, new(atomic.Uint64))
8490
req := testRequest()
8591

8692
res1, err := v.Merge(context.Background(), req)

service/runway/server/main.go

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"strconv"
2626
"strings"
2727
"sync"
28+
"sync/atomic"
2829
"syscall"
2930
"time"
3031

@@ -314,10 +315,10 @@ func newMergerFactory(logger *zap.Logger, scope tally.Scope) (merger.Factory, er
314315
// Marker-driven outcomes, for e2e tests that need Runway to fail on
315316
// demand without a git checkout. Never production.
316317
logger.Info("MERGER=fake; using marker-driven fake merger")
317-
return &fakeMergerFactory{merger: fake.New()}, nil
318+
return &fakeMergerFactory{seq: new(atomic.Uint64)}, nil
318319
case "noop":
319320
logger.Info("MERGER=noop; using noop merger")
320-
return &noopMergerFactory{}, nil
321+
return &noopMergerFactory{seq: new(atomic.Uint64)}, nil
321322
case "", "git":
322323
// Fall through to the merge-environment default below.
323324
default:
@@ -327,7 +328,7 @@ func newMergerFactory(logger *zap.Logger, scope tally.Scope) (merger.Factory, er
327328
checkoutPath := os.Getenv("MERGE_CHECKOUT_PATH")
328329
if checkoutPath == "" {
329330
logger.Info("MERGE_CHECKOUT_PATH not set; using noop merger")
330-
return &noopMergerFactory{}, nil
331+
return &noopMergerFactory{seq: new(atomic.Uint64)}, nil
331332
}
332333

333334
defaultStrategy, err := parseStrategy(os.Getenv("MERGE_DEFAULT_STRATEGY"))
@@ -370,7 +371,8 @@ func newMergerFactory(logger *zap.Logger, scope tally.Scope) (merger.Factory, er
370371

371372
// gitMergerFactory returns a single git-backed merger for every queue. The
372373
// merger owns one checkout and serializes its own operations, so one instance
373-
// is shared across queues. A deployment that lands multiple targets wires a
374+
// is shared across queues — which is why this is the one merger factory that
375+
// does not forward its Config. A deployment that lands multiple targets wires a
374376
// factory with a per-queue map instead.
375377
type gitMergerFactory struct {
376378
merger merger.Merger
@@ -380,20 +382,26 @@ func (f *gitMergerFactory) For(_ merger.Config) (merger.Merger, error) {
380382
return f.merger, nil
381383
}
382384

383-
type noopMergerFactory struct{}
385+
// noopMergerFactory builds a noop merger per queue, bound to that queue's
386+
// config. The synthetic revision-id counter is held here rather than on the
387+
// merger so ids stay unique across every queue in the process.
388+
type noopMergerFactory struct {
389+
seq *atomic.Uint64
390+
}
384391

385-
func (f *noopMergerFactory) For(_ merger.Config) (merger.Merger, error) {
386-
return noop.New(), nil
392+
func (f *noopMergerFactory) For(cfg merger.Config) (merger.Merger, error) {
393+
return noop.New(cfg, f.seq), nil
387394
}
388395

389-
// fakeMergerFactory shares one fake merger across queues so the synthetic
390-
// revision ids it mints stay unique for the lifetime of the process.
396+
// fakeMergerFactory builds a fake merger per queue, bound to that queue's
397+
// config. As with noop, the revision-id counter lives on the factory so ids
398+
// stay unique for the lifetime of the process.
391399
type fakeMergerFactory struct {
392-
merger merger.Merger
400+
seq *atomic.Uint64
393401
}
394402

395-
func (f *fakeMergerFactory) For(_ merger.Config) (merger.Merger, error) {
396-
return f.merger, nil
403+
func (f *fakeMergerFactory) For(cfg merger.Config) (merger.Merger, error) {
404+
return fake.New(cfg, f.seq), nil
397405
}
398406

399407
// parseStrategy maps the MERGE_DEFAULT_STRATEGY env value to a concrete merge

service/stovepipe/server/main.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,16 +133,16 @@ func (f *inMemoryCounterFactory) For(config counter.Config) (counter.Counter, er
133133
type fakeSourceControlFactory struct{}
134134

135135
func (fakeSourceControlFactory) For(cfg sourcecontrol.Config) (sourcecontrol.SourceControl, error) {
136-
return sourcecontrolfake.New([]string{fmt.Sprintf("git://%s/HEAD", cfg.QueueName)}), nil
136+
return sourcecontrolfake.New(cfg, []string{fmt.Sprintf("git://%s/HEAD", cfg.QueueName)}), nil
137137
}
138138

139-
// fakeBuildRunnerFactory is the example BuildRunner factory: every queue shares the same
140-
// stateless fake runner, which succeeds unless a caller embeds a failure marker in the head
141-
// URI. A real deployment supplies a backend-specific factory (e.g. Buildkite, per queue).
139+
// fakeBuildRunnerFactory is the example BuildRunner factory: every queue gets a stateless fake
140+
// runner bound to its own config, which succeeds unless a caller embeds a failure marker in the
141+
// head URI. A real deployment supplies a backend-specific factory (e.g. Buildkite, per queue).
142142
type fakeBuildRunnerFactory struct{}
143143

144-
func (fakeBuildRunnerFactory) For(_ buildrunner.Config) (buildrunner.BuildRunner, error) {
145-
return buildrunnerfake.New(), nil
144+
func (fakeBuildRunnerFactory) For(cfg buildrunner.Config) (buildrunner.BuildRunner, error) {
145+
return buildrunnerfake.New(cfg), nil
146146
}
147147

148148
func main() {

service/submitqueue/orchestrator/server/BUILD.bazel

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
load("@rules_go//go:def.bzl", "go_binary", "go_cross_binary", "go_library")
1+
load("@rules_go//go:def.bzl", "go_binary", "go_cross_binary", "go_library", "go_test")
22

33
exports_files(
44
["docker-compose.yml"],
@@ -49,6 +49,7 @@ go_library(
4949
"//submitqueue/extension/speculation/speculator/standard:go_default_library",
5050
"//submitqueue/extension/storage:go_default_library",
5151
"//submitqueue/extension/storage/mysql:go_default_library",
52+
"//submitqueue/extension/validator:go_default_library",
5253
"//submitqueue/extension/validator/fake:go_default_library",
5354
"//submitqueue/orchestrator:go_default_library",
5455
"@com_github_go_sql_driver_mysql//:go_default_library",
@@ -87,3 +88,19 @@ filegroup(
8788
],
8889
visibility = ["//test:__subpackages__"],
8990
)
91+
92+
go_test(
93+
name = "go_default_test",
94+
srcs = ["profiles_test.go"],
95+
embed = [":orchestrator_lib"], # keep
96+
deps = [
97+
"//submitqueue/extension/buildrunner:go_default_library",
98+
"//submitqueue/extension/changeprovider:go_default_library",
99+
"//submitqueue/extension/conflict:go_default_library",
100+
"//submitqueue/extension/scorer:go_default_library",
101+
"//submitqueue/extension/speculation/speculator:go_default_library",
102+
"//submitqueue/extension/storage:go_default_library",
103+
"@com_github_stretchr_testify//assert:go_default_library",
104+
"@com_github_stretchr_testify//require:go_default_library",
105+
],
106+
)

0 commit comments

Comments
 (0)