Skip to content

Commit 30458c9

Browse files
committed
feat(buildrunner): drop per-call queueName, add Factory, wire per-queue
## Summary ### Why? `BuildRunner.Trigger` took a `queueName` argument that selected the runner-specific job configuration on every call. That put queue routing on the hot path and on a single verb, leaving `Status` and `Cancel` to rediscover the queue from the build ID. The queue belongs at construction time, not per call. ### What? - Drop `queueName` from `BuildRunner.Trigger`; the verbs speak only in builds and changes. - Add a `Factory` interface (`New(cfg Config) (BuildRunner, error)`) and a `Config` struct carrying `QueueID`. A runner is bound to its Config at construction; backends extend `Config` with their own settings. - noop: add `NewFactory()`; keep `New()`. - Wire the factory end to end: the build and buildsignal controllers hold a `buildrunner.Factory` and build a runner per queue via `New(Config{QueueID: batch.Queue})`. buildsignal loads the batch to resolve the queue (TODO: denormalize queue onto the Build). The example orchestrator wires `buildnoop.NewFactory()`. - Docs: BuildRunner is no longer described as a "singleton" but must stay safe for concurrent use. Updated the RFC (Construction section) and README. Caching of runners per queue is intentionally omitted for now. ## Test Plan ✅ `make mocks`, `make gazelle`, `make tidy`, `make fmt` ✅ `bazel build //...` ✅ `make test` (unit), incl. buildrunner + build + buildsignal
1 parent 1106427 commit 30458c9

13 files changed

Lines changed: 189 additions & 40 deletions

File tree

doc/rfc/build-runner.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,27 @@ The build stage needs a vendor-agnostic abstraction for talking to a Build Runne
3838

3939
`BuildRunner` exposes three verbs, all keyed by a build identifier (`entity.BuildID`):
4040

41-
- **`Trigger`** — submit a build for a queue, given the ordered `base` and `head` change sets plus a free-form metadata map; returns the new build's ID. Runner-side work is asynchronous.
41+
- **`Trigger`** — submit a build given the ordered `base` and `head` change sets plus a free-form metadata map; returns the new build's ID. Runner-side work is asynchronous.
4242
- **`Status`** — fetch the current `BuildStatus` and runner-defined metadata for a build; MAY round-trip to the runner.
4343
- **`Cancel`** — request cancellation; returns once the request reaches the runner, not once the build stops.
4444

4545
See `extension/buildrunner/build_runner.go` for the exact Go signatures. The sections below record why the contract is shaped this way.
4646

47+
### Construction: a Factory, queue bound at build time
48+
49+
A `BuildRunner` does not take a queue selector on any verb. The queue whose job configuration a runner uses is fixed when the runner is constructed, and runners are constructed by a `Factory`.
50+
51+
- **`Factory`** — produces `BuildRunner` instances from a `Config`. A controller that drives builds for several queues holds one `Factory` and obtains one `BuildRunner` per queue.
52+
- **`Config`** — the configuration the factory binds in: a `QueueID` selecting the queue whose job definition the runner builds against, plus any backend-specific settings (endpoints, credentials, defaults) a concrete implementation adds.
53+
54+
Why bind the queue at construction rather than pass it per call:
55+
56+
- A runner's connection pool, caches, and job defaults are all keyed to one queue's configuration. Passing the queue per call would force every implementation to re-resolve that configuration on the hot path, or to maintain an internal queue→config map the factory already expresses cleanly.
57+
- It keeps the per-call verbs (`Trigger`, `Status`, `Cancel`) free of routing concerns — they speak only in builds and changes.
58+
- It matches the rest of the extension family, whose implementations are bound to their configuration at construction.
59+
60+
Rejected: a `queueName` argument on `Trigger`. It put routing on the hot path and on a single verb, leaving `Status` and `Cancel` to rediscover the queue from the build ID. Carrying the selection in `Config` keeps each runner bound to a single queue.
61+
4762
### Trigger: base + head
4863

4964
`Trigger` takes two ordered lists of changes and a free-form metadata map:
@@ -125,7 +140,7 @@ Rejected: long-polling on `Status`. Not every backend supports efficient server-
125140

126141
### Lifecycle
127142

128-
Implementations are long-lived singletons bound to provider config at construction. Every method is concurrent-safe; connection pools and caches live inside the manager; anything that must survive a restart belongs in persistent storage, not the manager.
143+
Implementations are constructed by a `Factory` and bound to one queue's provider config at construction (see *Construction* above). They may be shared and called concurrently, so every method must be concurrent-safe; connection pools and caches live inside the manager; anything that must survive a restart belongs in persistent storage, not the manager.
129144

130145
### Transient failures
131146

example/server/orchestrator/main.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,13 @@ func run() error {
218218
return fmt.Errorf("failed to create pusher: %w", err)
219219
}
220220

221-
// Create build runner. The noop runner is the pass-through default
222-
// (every build immediately succeeds) until a real backend is wired in.
223-
br := buildnoop.New()
221+
// Create the build runner factory. The noop factory is the pass-through
222+
// default (every build immediately succeeds) until a real backend is
223+
// wired in; controllers build a runner per queue from it.
224+
runnerFactory := buildnoop.NewFactory()
224225

225226
// Register controllers
226-
if err := registerControllers(c, logger.Sugar(), scope, registry, mc, cp, psh, br, cnt, store, changeStore); err != nil {
227+
if err := registerControllers(c, logger.Sugar(), scope, registry, mc, cp, psh, runnerFactory, cnt, store, changeStore); err != nil {
227228
return err
228229
}
229230

@@ -408,7 +409,7 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe
408409
// │ │ │
409410
// └────────┴───────────────────────┘
410411

411-
func registerControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope tally.Scope, registry consumer.TopicRegistry, mc mergechecker.MergeChecker, cp changeprovider.ChangeProvider, psh pusher.Pusher, br buildrunner.BuildRunner, cnt counter.Counter, store storage.Storage, changeStore changestore.ChangeStore) error {
412+
func registerControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope tally.Scope, registry consumer.TopicRegistry, mc mergechecker.MergeChecker, cp changeprovider.ChangeProvider, psh pusher.Pusher, runnerFactory buildrunner.Factory, cnt counter.Counter, store storage.Storage, changeStore changestore.ChangeStore) error {
412413
requestController := start.NewController(
413414
logger,
414415
scope,
@@ -494,7 +495,7 @@ func registerControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope t
494495
logger,
495496
scope,
496497
store,
497-
br,
498+
runnerFactory,
498499
registry,
499500
consumer.TopicKeyBuild,
500501
"orchestrator-build",
@@ -507,7 +508,7 @@ func registerControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope t
507508
logger,
508509
scope,
509510
store,
510-
br,
511+
runnerFactory,
511512
registry,
512513
consumer.TopicKeyBuildSignal,
513514
"orchestrator-buildsignal",

extension/buildrunner/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ See [`doc/rfc/build-runner.md`](../../doc/rfc/build-runner.md) for the contract
66

77
## Adding a new backend
88

9-
1. Create `extension/buildrunner/{backend}/` with a `BuildRunner` implementation bound to its runner configuration at construction.
9+
1. Create `extension/buildrunner/{backend}/` with a `Factory` whose `New` returns a `BuildRunner` bound to one queue's job configuration. The runner verbs carry no queue selector — that selection lives in the `Config` passed to the factory.
1010
2. Map the `base` and `head` change slices onto the backend's build primitives (apply `base`, apply `head`, validate the result).
1111
3. Map the runner's lifecycle states down to the `BuildStatus` values: `Accepted` (accepted for execution), `Running` (executing), and the terminal `Succeeded` / `Failed` / `Cancelled`.
1212
4. Implement internal reconnect / retry so transient failures surface as plain errors without blocking the caller.

extension/buildrunner/build_runner.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,34 @@ import (
2222
"github.com/uber/submitqueue/entity"
2323
)
2424

25+
// Config carries the configuration a Factory binds into a BuildRunner: the
26+
// queue whose job definition the runner builds against, plus any
27+
// backend-specific settings (endpoints, credentials, defaults) a concrete
28+
// implementation adds.
29+
type Config struct {
30+
// QueueID identifies the queue whose job configuration this runner
31+
// builds against.
32+
QueueID string
33+
}
34+
35+
// Factory constructs BuildRunner instances from a Config. A BuildRunner is
36+
// bound to its Config at construction; the per-build verbs take no queue
37+
// selector. A controller that serves multiple queues holds a Factory and
38+
// calls New with each batch's queue (see Config.QueueID).
39+
//
40+
// Implementations must be safe for concurrent use by multiple goroutines.
41+
type Factory interface {
42+
// New returns a BuildRunner for cfg, ready to trigger builds. Returns an
43+
// error if a runner cannot be constructed from cfg (e.g. invalid
44+
// configuration or an unreachable backend).
45+
New(cfg Config) (BuildRunner, error)
46+
}
47+
2548
// BuildRunner triggers builds against an external Build Runner, queries
26-
// their status, and cancels them.
49+
// their status, and cancels them. A BuildRunner is bound to its Config at
50+
// construction (see Factory); the verbs below take no queue selector.
2751
//
28-
// Implementations are long-lived singletons and must:
52+
// Implementations may be shared and called concurrently, and must:
2953
// - make every method safe for concurrent use by multiple goroutines;
3054
// - recover from transient connectivity failures internally, returning
3155
// plain errors during the recovery window rather than blocking the
@@ -55,11 +79,9 @@ type BuildRunner interface {
5579
// asynchronously. Callers learn the build's progress via Status, not
5680
// via Trigger.
5781
//
58-
// queueName selects the runner-specific job configuration.
5982
// Returns an error if the request is invalid.
6083
Trigger(
6184
ctx context.Context,
62-
queueName string,
6385
base []entity.Change,
6486
head []entity.Change,
6587
metadata entity.BuildMetadata,

extension/buildrunner/mock/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ go_library(
77
visibility = ["//visibility:public"],
88
deps = [
99
"//entity",
10+
"//extension/buildrunner",
1011
"@org_uber_go_mock//gomock",
1112
],
1213
)

extension/buildrunner/mock/build_runner_mock.go

Lines changed: 44 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

extension/buildrunner/noop/noop.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,27 @@ type runner struct {
3434
counter atomic.Uint64
3535
}
3636

37+
// factory builds no-op runners. It ignores the supplied Config.
38+
type factory struct{}
39+
40+
// NewFactory returns a buildrunner.Factory that produces no-op runners.
41+
func NewFactory() buildrunner.Factory {
42+
return factory{}
43+
}
44+
45+
// New returns a no-op buildrunner.BuildRunner. The Config is ignored.
46+
func (factory) New(_ buildrunner.Config) (buildrunner.BuildRunner, error) {
47+
return New(), nil
48+
}
49+
3750
// New returns a buildrunner.BuildRunner that performs no real work.
3851
func New() buildrunner.BuildRunner {
3952
return &runner{}
4053
}
4154

4255
// Trigger returns a unique build ID without contacting any runner.
4356
// Inputs are ignored.
44-
func (r *runner) Trigger(_ context.Context, _ string, _ []entity.Change, _ []entity.Change, _ entity.BuildMetadata) (entity.BuildID, error) {
57+
func (r *runner) Trigger(_ context.Context, _ []entity.Change, _ []entity.Change, _ entity.BuildMetadata) (entity.BuildID, error) {
4558
return entity.BuildID{ID: fmt.Sprintf("noop-%d", r.counter.Add(1))}, nil
4659
}
4760

extension/buildrunner/noop/noop_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,18 @@ func TestNew_ImplementsInterface(t *testing.T) {
2828
var _ buildrunner.BuildRunner = New()
2929
}
3030

31+
func TestNewFactory_ImplementsInterface(t *testing.T) {
32+
var f buildrunner.Factory = NewFactory()
33+
r, err := f.New(buildrunner.Config{QueueID: "queueA"})
34+
require.NoError(t, err)
35+
assert.NotNil(t, r)
36+
}
37+
3138
func TestRunner_Trigger(t *testing.T) {
3239
r := New()
3340
ctx := context.Background()
3441

35-
id1, err := r.Trigger(ctx, "queueA",
42+
id1, err := r.Trigger(ctx,
3643
[]entity.Change{{URIs: []string{"github://owner/repo/pull/1"}}},
3744
[]entity.Change{{URIs: []string{"github://owner/repo/pull/2"}}},
3845
entity.BuildMetadata{"requester": "alice"},
@@ -41,7 +48,7 @@ func TestRunner_Trigger(t *testing.T) {
4148
assert.NotEmpty(t, id1.ID)
4249

4350
// IDs are unique across calls, even with empty inputs.
44-
id2, err := r.Trigger(ctx, "queueA", nil, nil, nil)
51+
id2, err := r.Trigger(ctx, nil, nil, nil)
4552
require.NoError(t, err)
4653
assert.NotEqual(t, id1, id2)
4754
}

orchestrator/controller/build/build.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ type Controller struct {
3636
logger *zap.SugaredLogger
3737
metricsScope tally.Scope
3838
store storage.Storage
39-
buildRunner buildrunner.BuildRunner
39+
runnerFactory buildrunner.Factory
4040
registry consumer.TopicRegistry
4141
topicKey consumer.TopicKey
4242
consumerGroup string
@@ -50,7 +50,7 @@ func NewController(
5050
logger *zap.SugaredLogger,
5151
scope tally.Scope,
5252
store storage.Storage,
53-
buildRunner buildrunner.BuildRunner,
53+
runnerFactory buildrunner.Factory,
5454
registry consumer.TopicRegistry,
5555
topicKey consumer.TopicKey,
5656
consumerGroup string,
@@ -59,7 +59,7 @@ func NewController(
5959
logger: logger.Named("build_controller"),
6060
metricsScope: scope.SubScope("build_controller"),
6161
store: store,
62-
buildRunner: buildRunner,
62+
runnerFactory: runnerFactory,
6363
registry: registry,
6464
topicKey: topicKey,
6565
consumerGroup: consumerGroup,
@@ -112,10 +112,16 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (r
112112
return fmt.Errorf("failed to assemble head changes for batch %s: %w", batch.ID, err)
113113
}
114114

115-
// Trigger the build with the configured build manager. metadata is nil
116-
// until a caller-supplied source materializes (e.g. requester / ticket
117-
// pulled off the originating LandRequest).
118-
buildID, err := c.buildRunner.Trigger(ctx, batch.Queue, base, head, nil)
115+
// Resolve the BuildRunner for this batch's queue and trigger the build.
116+
// metadata is nil until a caller-supplied source materializes (e.g.
117+
// requester / ticket pulled off the originating LandRequest).
118+
runner, err := c.runnerFactory.New(buildrunner.Config{QueueID: batch.Queue})
119+
if err != nil {
120+
metrics.NamedCounter(c.metricsScope, opName, "runner_errors", 1)
121+
return fmt.Errorf("failed to create build runner for queue %s: %w", batch.Queue, err)
122+
}
123+
124+
buildID, err := runner.Trigger(ctx, base, head, nil)
119125
if err != nil {
120126
metrics.NamedCounter(c.metricsScope, opName, "trigger_errors", 1)
121127
return fmt.Errorf("failed to trigger build for batch %s: %w", batch.ID, err)

0 commit comments

Comments
 (0)