Skip to content

Commit 500bb73

Browse files
ubettigoleclaude
andauthored
refactor(gateway): make CancelController operate on entities (#360)
## Summary Remove proto coupling from CancelController.Cancel by changing its signature from (*pb.CancelRequest) (*pb.CancelResponse, error) to (entity.CancelRequest) error. The empty CancelResponse is now constructed at the handler layer. Add mapper.ProtoToCancelRequest in the mapper subpackage and wire it through GatewayServer.Cancel. Rename entity/cancel_request.go to entity/cancel.go for consistency with entity/land.go. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> ## Test Plan ## Issues ## Stack 1. #359 1. @ #360 1. #363 1. #361 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7cbbbe6 commit 500bb73

9 files changed

Lines changed: 128 additions & 46 deletions

File tree

service/submitqueue/gateway/server/main.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,13 @@ func (s *GatewayServer) Land(ctx context.Context, req *pb.LandRequest) (*pb.Land
7777
return &pb.LandResponse{Sqid: result.ID}, nil
7878
}
7979

80-
// Cancel delegates to the controller
80+
// Cancel maps the wire request to an entity, delegates to the controller, and
81+
// returns an empty response on success.
8182
func (s *GatewayServer) Cancel(ctx context.Context, req *pb.CancelRequest) (*pb.CancelResponse, error) {
82-
return s.cancelController.Cancel(ctx, req)
83+
if err := s.cancelController.Cancel(ctx, mapper.ProtoToCancelRequest(req)); err != nil {
84+
return nil, err
85+
}
86+
return &pb.CancelResponse{}, nil
8387
}
8488

8589
// Status delegates to the controller

service/submitqueue/gateway/server/mapper/BUILD.bazel

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ load("@rules_go//go:def.bzl", "go_library", "go_test")
22

33
go_library(
44
name = "go_default_library",
5-
srcs = ["land.go"],
5+
srcs = [
6+
"cancel.go",
7+
"land.go",
8+
],
69
importpath = "github.com/uber/submitqueue/service/submitqueue/gateway/server/mapper",
710
visibility = ["//visibility:public"],
811
deps = [
@@ -16,7 +19,10 @@ go_library(
1619

1720
go_test(
1821
name = "go_default_test",
19-
srcs = ["land_test.go"],
22+
srcs = [
23+
"cancel_test.go",
24+
"land_test.go",
25+
],
2026
embed = [":go_default_library"],
2127
deps = [
2228
"//api/base/change/protopb:go_default_library",
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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 mapper
16+
17+
import (
18+
pb "github.com/uber/submitqueue/api/submitqueue/gateway/protopb"
19+
"github.com/uber/submitqueue/submitqueue/entity"
20+
)
21+
22+
// ProtoToCancelRequest maps the wire CancelRequest to the entity.CancelRequest
23+
// the controller operates on.
24+
func ProtoToCancelRequest(req *pb.CancelRequest) entity.CancelRequest {
25+
return entity.CancelRequest{
26+
ID: req.GetSqid(),
27+
Reason: req.GetReason(),
28+
}
29+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 mapper
16+
17+
import (
18+
"testing"
19+
20+
"github.com/stretchr/testify/assert"
21+
pb "github.com/uber/submitqueue/api/submitqueue/gateway/protopb"
22+
"github.com/uber/submitqueue/submitqueue/entity"
23+
)
24+
25+
func TestProtoToCancelRequest(t *testing.T) {
26+
tests := []struct {
27+
name string
28+
req *pb.CancelRequest
29+
expected entity.CancelRequest
30+
}{
31+
{
32+
name: "maps sqid and reason",
33+
req: &pb.CancelRequest{Sqid: "test-queue/42", Reason: "obsolete change"},
34+
expected: entity.CancelRequest{ID: "test-queue/42", Reason: "obsolete change"},
35+
},
36+
{
37+
name: "maps sqid without reason",
38+
req: &pb.CancelRequest{Sqid: "test-queue/1"},
39+
expected: entity.CancelRequest{ID: "test-queue/1"},
40+
},
41+
{
42+
name: "empty request yields zero value",
43+
req: &pb.CancelRequest{},
44+
expected: entity.CancelRequest{},
45+
},
46+
}
47+
48+
for _, tt := range tests {
49+
t.Run(tt.name, func(t *testing.T) {
50+
got := ProtoToCancelRequest(tt.req)
51+
assert.Equal(t, tt.expected, got)
52+
})
53+
}
54+
}

submitqueue/entity/BUILD.bazel

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ go_library(
77
"batch_changes.go",
88
"batch_dependent.go",
99
"build.go",
10-
"cancel_request.go",
10+
"cancel.go",
1111
"change_provider.go",
1212
"change_record.go",
1313
"conflict.go",
@@ -35,7 +35,7 @@ go_test(
3535
srcs = [
3636
"batch_test.go",
3737
"build_test.go",
38-
"cancel_request_test.go",
38+
"cancel_test.go",
3939
"land_test.go",
4040
"queue_test.go",
4141
"request_log_test.go",

submitqueue/gateway/controller/cancel.go

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import (
2020
"time"
2121

2222
"github.com/uber-go/tally"
23-
pb "github.com/uber/submitqueue/api/submitqueue/gateway/protopb"
2423
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
2524
"github.com/uber/submitqueue/platform/consumer"
2625
"github.com/uber/submitqueue/platform/errs"
@@ -62,26 +61,21 @@ func NewCancelController(logger *zap.SugaredLogger, scope tally.Scope, requestLo
6261
// completion before the cancel propagates may still land. The RequestStatusCancelling
6362
// entry written here records the user's intent; the terminal outcome is reflected by a
6463
// later RequestStatusCancelled (orchestrator side) or RequestStatusLanded entry.
65-
func (c *CancelController) Cancel(ctx context.Context, req *pb.CancelRequest) (*pb.CancelResponse, error) {
64+
func (c *CancelController) Cancel(ctx context.Context, req entity.CancelRequest) error {
6665
start := time.Now()
6766
defer func() {
6867
c.metricsScope.Timer("cancel_request_latency").Record(time.Since(start))
6968
}()
7069

7170
c.metricsScope.Counter("cancel_request_count").Inc(1)
7271

73-
if req.Sqid == "" {
74-
return nil, fmt.Errorf("CancelController requires the request to have a sqid specified: %w", ErrInvalidRequest)
75-
}
76-
77-
cancelRequest := entity.CancelRequest{
78-
ID: req.Sqid,
79-
Reason: req.Reason,
72+
if req.ID == "" {
73+
return fmt.Errorf("CancelController requires the request to have a sqid specified: %w", ErrInvalidRequest)
8074
}
8175

8276
c.logger.Debugw("cancel request received",
83-
"sqid", cancelRequest.ID,
84-
"reason", cancelRequest.Reason,
77+
"sqid", req.ID,
78+
"reason", req.Reason,
8579
)
8680

8781
// Verify the sqid exists before recording intent or publishing. Cancel is opt-in
@@ -90,37 +84,37 @@ func (c *CancelController) Cancel(ctx context.Context, req *pb.CancelRequest) (*
9084
// controller writes its "accepted" log entry synchronously to the same store, so
9185
// a NotFound here reliably means "this sqid was never accepted by the gateway"
9286
// rather than "in flight" — there is no false-negative race window.
93-
if _, err := c.requestLogStore.List(ctx, cancelRequest.ID); err != nil {
87+
if _, err := c.requestLogStore.List(ctx, req.ID); err != nil {
9488
if storage.IsNotFound(err) {
9589
c.metricsScope.Counter("cancel_request_not_found").Inc(1)
96-
return nil, errs.NewUserError(&RequestNotFoundError{Sqid: cancelRequest.ID})
90+
return errs.NewUserError(&RequestNotFoundError{Sqid: req.ID})
9791
}
98-
return nil, fmt.Errorf("CancelController failed to look up request log for sqid=%s: %w", cancelRequest.ID, err)
92+
return fmt.Errorf("CancelController failed to look up request log for sqid=%s: %w", req.ID, err)
9993
}
10094

10195
// Record the user's intent in the request log before publishing. Writing direct to the
10296
// store (rather than via the log topic) keeps the gateway-emitted entry consistent with
10397
// the Land "accepted" entry and guarantees the entry is visible the moment Cancel returns.
10498
metadata := map[string]string{}
105-
if cancelRequest.Reason != "" {
106-
metadata["reason"] = cancelRequest.Reason
99+
if req.Reason != "" {
100+
metadata["reason"] = req.Reason
107101
}
108-
logEntry := entity.NewRequestLog(cancelRequest.ID, entity.RequestStatusCancelling, 0, "", metadata)
102+
logEntry := entity.NewRequestLog(req.ID, entity.RequestStatusCancelling, 0, "", metadata)
109103
if err := c.requestLogStore.Insert(ctx, logEntry); err != nil {
110-
return nil, fmt.Errorf("CancelController failed to insert cancelling log for sqid=%s: %w", cancelRequest.ID, err)
104+
return fmt.Errorf("CancelController failed to insert cancelling log for sqid=%s: %w", req.ID, err)
111105
}
112106

113-
if err := c.publishToQueue(ctx, cancelRequest); err != nil {
114-
return nil, fmt.Errorf("CancelController failed to publish cancel request to queue: %w", err)
107+
if err := c.publishToQueue(ctx, req); err != nil {
108+
return fmt.Errorf("CancelController failed to publish cancel request to queue: %w", err)
115109
}
116110

117111
c.logger.Infow("cancel request published to queue",
118-
"sqid", cancelRequest.ID,
112+
"sqid", req.ID,
119113
"topic_key", topickey.TopicKeyCancel,
120114
)
121115
c.metricsScope.Counter("cancel_publish_success").Inc(1)
122116

123-
return &pb.CancelResponse{}, nil
117+
return nil
124118
}
125119

126120
// publishToQueue publishes a cancel request to the cancel queue for async processing.

submitqueue/gateway/controller/cancel_test.go

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import (
2222
"github.com/stretchr/testify/assert"
2323
"github.com/stretchr/testify/require"
2424
"github.com/uber-go/tally"
25-
pb "github.com/uber/submitqueue/api/submitqueue/gateway/protopb"
2625
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
2726
"github.com/uber/submitqueue/platform/consumer"
2827
"github.com/uber/submitqueue/platform/errs"
@@ -68,6 +67,11 @@ func newRequestLogStoreNoop(t *testing.T, ctrl *gomock.Controller) *storagemock.
6867
return store
6968
}
7069

70+
// testCancelRequest returns a valid entity.CancelRequest for testing.
71+
func testCancelRequest(sqid string, reason string) entity.CancelRequest {
72+
return entity.CancelRequest{ID: sqid, Reason: reason}
73+
}
74+
7175
func TestNewCancelController(t *testing.T) {
7276
ctrl := gomock.NewController(t)
7377

@@ -81,11 +85,9 @@ func TestCancel_HappyPath(t *testing.T) {
8185
controller := NewCancelController(zap.NewNop().Sugar(), tally.NoopScope, newRequestLogStoreNoop(t, ctrl), newCancelTestRegistryWithNoopPublisher(t, ctrl))
8286
ctx := context.Background()
8387

84-
req := &pb.CancelRequest{Sqid: "test-queue/42", Reason: "user changed their mind"}
85-
resp, err := controller.Cancel(ctx, req)
88+
err := controller.Cancel(ctx, testCancelRequest("test-queue/42", "user changed their mind"))
8689

8790
require.NoError(t, err)
88-
require.NotNil(t, resp)
8991
}
9092

9193
func TestCancel_ReturnsErrorOnEmptySqid(t *testing.T) {
@@ -94,8 +96,7 @@ func TestCancel_ReturnsErrorOnEmptySqid(t *testing.T) {
9496
controller := NewCancelController(zap.NewNop().Sugar(), tally.NoopScope, newRequestLogStoreNoop(t, ctrl), newCancelTestRegistryWithNoopPublisher(t, ctrl))
9597
ctx := context.Background()
9698

97-
req := &pb.CancelRequest{Sqid: "", Reason: "anything"}
98-
_, err := controller.Cancel(ctx, req)
99+
err := controller.Cancel(ctx, testCancelRequest("", "anything"))
99100

100101
require.Error(t, err)
101102
assert.True(t, IsInvalidRequest(err))
@@ -119,8 +120,7 @@ func TestCancel_PublishesToQueue(t *testing.T) {
119120
controller := NewCancelController(zap.NewNop().Sugar(), tally.NoopScope, newRequestLogStoreNoop(t, ctrl), registry)
120121
ctx := context.Background()
121122

122-
req := &pb.CancelRequest{Sqid: "my-queue/7", Reason: "obsolete change"}
123-
_, err := controller.Cancel(ctx, req)
123+
err := controller.Cancel(ctx, testCancelRequest("my-queue/7", "obsolete change"))
124124
require.NoError(t, err)
125125

126126
assert.Equal(t, "cancel", publishedTopic)
@@ -160,8 +160,7 @@ func TestCancel_InsertsCancellingLog(t *testing.T) {
160160

161161
controller := NewCancelController(zap.NewNop().Sugar(), tally.NoopScope, logStore, registry)
162162

163-
req := &pb.CancelRequest{Sqid: "my-queue/42", Reason: "obsolete change"}
164-
_, err := controller.Cancel(context.Background(), req)
163+
err := controller.Cancel(context.Background(), testCancelRequest("my-queue/42", "obsolete change"))
165164
require.NoError(t, err)
166165

167166
assert.Equal(t, "my-queue/42", insertedLog.RequestID)
@@ -180,11 +179,10 @@ func TestCancel_LogInsertFailure(t *testing.T) {
180179
logStore.EXPECT().Insert(gomock.Any(), gomock.Any()).Return(fmt.Errorf("db unavailable"))
181180

182181
registry, publisher := newCancelTestRegistry(t, ctrl)
183-
// No Publish expectation: log insert must fail before publish runs.
184182
_ = publisher
185183

186184
controller := NewCancelController(zap.NewNop().Sugar(), tally.NoopScope, logStore, registry)
187-
_, err := controller.Cancel(context.Background(), &pb.CancelRequest{Sqid: "q/1"})
185+
err := controller.Cancel(context.Background(), testCancelRequest("q/1", ""))
188186
require.Error(t, err)
189187
}
190188

@@ -197,8 +195,7 @@ func TestCancel_ReturnsErrorOnPublishFailure(t *testing.T) {
197195
controller := NewCancelController(zap.NewNop().Sugar(), tally.NoopScope, newRequestLogStoreNoop(t, ctrl), registry)
198196
ctx := context.Background()
199197

200-
req := &pb.CancelRequest{Sqid: "test-queue/1"}
201-
_, err := controller.Cancel(ctx, req)
198+
err := controller.Cancel(ctx, testCancelRequest("test-queue/1", ""))
202199

203200
require.Error(t, err)
204201
}
@@ -211,14 +208,12 @@ func TestCancel_UnknownSqidIsUserError(t *testing.T) {
211208

212209
logStore := storagemock.NewMockRequestLogStore(ctrl)
213210
logStore.EXPECT().List(gomock.Any(), "ghost/1").Return(nil, storage.ErrNotFound)
214-
// No Insert expectation: existence check must short-circuit before Insert.
215211

216212
registry, publisher := newCancelTestRegistry(t, ctrl)
217-
// No Publish expectation: existence check must short-circuit before Publish.
218213
_ = publisher
219214

220215
controller := NewCancelController(zap.NewNop().Sugar(), tally.NoopScope, logStore, registry)
221-
_, err := controller.Cancel(context.Background(), &pb.CancelRequest{Sqid: "ghost/1"})
216+
err := controller.Cancel(context.Background(), testCancelRequest("ghost/1", ""))
222217
require.Error(t, err)
223218
assert.True(t, IsRequestNotFound(err))
224219
assert.True(t, errs.IsUserError(err))
@@ -242,7 +237,7 @@ func TestCancel_RequestLogLookupFailure(t *testing.T) {
242237
_ = publisher
243238

244239
controller := NewCancelController(zap.NewNop().Sugar(), tally.NoopScope, logStore, registry)
245-
_, err := controller.Cancel(context.Background(), &pb.CancelRequest{Sqid: "q/1"})
240+
err := controller.Cancel(context.Background(), testCancelRequest("q/1", ""))
246241
require.Error(t, err)
247242
assert.False(t, errs.IsUserError(err))
248243
assert.False(t, IsRequestNotFound(err))

0 commit comments

Comments
 (0)