Skip to content

Commit d3f8423

Browse files
kevinlnewclaude
andcommitted
feat(runway): add merge-conflict-check and merge controllers
Two consumer controllers, one per operation: merge-conflict-check (dry-run, all errors nack) and merge (conflicts ack + publish failure result, infra errors nack). Both consume MergeRequest and publish MergeResult using the landed entity contract. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ac42742 commit d3f8423

6 files changed

Lines changed: 766 additions & 0 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "merge",
5+
srcs = ["merge.go"],
6+
importpath = "github.com/uber/submitqueue/runway/orchestrator/controller/merge",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//platform/base/messagequeue",
10+
"//platform/consumer",
11+
"//platform/metrics",
12+
"//runway/core/topickey",
13+
"//runway/entity",
14+
"//runway/extension/vcs",
15+
"@com_github_uber_go_tally//:tally",
16+
"@org_uber_go_zap//:zap",
17+
],
18+
)
19+
20+
go_test(
21+
name = "merge_test",
22+
srcs = ["merge_test.go"],
23+
embed = [":merge"],
24+
deps = [
25+
"//platform/base/change",
26+
"//platform/base/mergestrategy",
27+
"//platform/base/messagequeue",
28+
"//platform/consumer",
29+
"//platform/extension/messagequeue/mock",
30+
"//runway/core/topickey",
31+
"//runway/entity",
32+
"//runway/extension/vcs",
33+
"//runway/extension/vcs/mock",
34+
"@com_github_stretchr_testify//assert",
35+
"@com_github_stretchr_testify//require",
36+
"@com_github_uber_go_tally//:tally",
37+
"@org_uber_go_mock//gomock",
38+
"@org_uber_go_zap//zaptest",
39+
],
40+
)
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 merge
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
22+
"github.com/uber-go/tally"
23+
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
24+
"github.com/uber/submitqueue/platform/consumer"
25+
"github.com/uber/submitqueue/platform/metrics"
26+
"github.com/uber/submitqueue/runway/core/topickey"
27+
"github.com/uber/submitqueue/runway/entity"
28+
"github.com/uber/submitqueue/runway/extension/vcs"
29+
"go.uber.org/zap"
30+
)
31+
32+
var _ consumer.Controller = (*Controller)(nil)
33+
34+
// Controller handles merge queue messages. It applies the ordered steps,
35+
// commits the result to the remote, and publishes per-step outcomes back
36+
// to the signal queue. Conflicts are expected outcomes (ack + publish a
37+
// failure result); infrastructure errors are nacked for retry.
38+
type Controller struct {
39+
logger *zap.SugaredLogger
40+
metricsScope tally.Scope
41+
registry consumer.TopicRegistry
42+
vcsFactory vcs.Factory
43+
topicKey consumer.TopicKey
44+
consumerGroup string
45+
}
46+
47+
// Params are the parameters for creating a new merge controller.
48+
type Params struct {
49+
Registry consumer.TopicRegistry
50+
VCSFactory vcs.Factory
51+
TopicKey consumer.TopicKey
52+
ConsumerGroup string
53+
54+
Scope tally.Scope
55+
Logger *zap.SugaredLogger
56+
}
57+
58+
// NewController creates a new merge controller.
59+
func NewController(p Params) *Controller {
60+
return &Controller{
61+
logger: p.Logger.Named("merge_controller"),
62+
metricsScope: p.Scope.SubScope("merge_controller"),
63+
registry: p.Registry,
64+
vcsFactory: p.VCSFactory,
65+
topicKey: p.TopicKey,
66+
consumerGroup: p.ConsumerGroup,
67+
}
68+
}
69+
70+
func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (retErr error) {
71+
const opName = "process"
72+
73+
op := metrics.Begin(c.metricsScope, opName)
74+
defer func() { op.Complete(retErr) }()
75+
76+
msg := delivery.Message()
77+
78+
req, err := entity.MergeRequestFromBytes(msg.Payload)
79+
if err != nil {
80+
metrics.NamedCounter(c.metricsScope, opName, "deserialize_errors", 1)
81+
return fmt.Errorf("failed to deserialize merge request: %w", err)
82+
}
83+
84+
c.logger.Infow("received merge request",
85+
"request_id", req.ID,
86+
"queue", req.QueueName,
87+
"step_count", len(req.Steps),
88+
"attempt", delivery.Attempt(),
89+
)
90+
91+
v, err := c.vcsFactory.For(vcs.Config{QueueName: req.QueueName})
92+
if err != nil {
93+
metrics.NamedCounter(c.metricsScope, opName, "factory_errors", 1)
94+
return fmt.Errorf("failed to build VCS for queue %s: %w", req.QueueName, err)
95+
}
96+
97+
result, err := v.Land(ctx, req)
98+
switch {
99+
case err == nil:
100+
// Success — publish result with per-step output IDs.
101+
case errors.Is(err, vcs.ErrConflict):
102+
metrics.NamedCounter(c.metricsScope, opName, "conflicts", 1)
103+
c.logger.Infow("merge conflict",
104+
"request_id", req.ID,
105+
)
106+
conflictResult := entity.MergeResult{
107+
ID: req.ID,
108+
Success: false,
109+
Reason: err.Error(),
110+
}
111+
if pubErr := c.publishResult(ctx, conflictResult, req.QueueName); pubErr != nil {
112+
metrics.NamedCounter(c.metricsScope, opName, "publish_errors", 1)
113+
return fmt.Errorf("failed to publish conflict result for request %s: %w", req.ID, pubErr)
114+
}
115+
return nil
116+
default:
117+
metrics.NamedCounter(c.metricsScope, opName, "land_errors", 1)
118+
return fmt.Errorf("merge failed for request %s: %w", req.ID, err)
119+
}
120+
121+
if err := c.publishResult(ctx, result, req.QueueName); err != nil {
122+
metrics.NamedCounter(c.metricsScope, opName, "publish_errors", 1)
123+
return fmt.Errorf("failed to publish merge result for request %s: %w", req.ID, err)
124+
}
125+
126+
c.logger.Infow("published merge result",
127+
"request_id", req.ID,
128+
"success", result.Success,
129+
"step_count", len(result.Steps),
130+
)
131+
132+
return nil
133+
}
134+
135+
func (c *Controller) publishResult(ctx context.Context, result entity.MergeResult, partitionKey string) error {
136+
payload, err := result.ToBytes()
137+
if err != nil {
138+
return fmt.Errorf("failed to serialize merge result: %w", err)
139+
}
140+
141+
q, ok := c.registry.Queue(topickey.TopicKeyMergeSignal)
142+
if !ok {
143+
return fmt.Errorf("no queue registered for topic key %s", topickey.TopicKeyMergeSignal)
144+
}
145+
146+
topicName, ok := c.registry.TopicName(topickey.TopicKeyMergeSignal)
147+
if !ok {
148+
return fmt.Errorf("no topic name registered for topic key %s", topickey.TopicKeyMergeSignal)
149+
}
150+
151+
msg := entityqueue.NewMessage(result.ID, payload, partitionKey, nil)
152+
if err := q.Publisher().Publish(ctx, topicName, msg); err != nil {
153+
return fmt.Errorf("failed to publish message: %w", err)
154+
}
155+
156+
return nil
157+
}
158+
159+
func (c *Controller) Name() string { return "merge" }
160+
func (c *Controller) TopicKey() consumer.TopicKey { return c.topicKey }
161+
func (c *Controller) ConsumerGroup() string { return c.consumerGroup }

0 commit comments

Comments
 (0)