Skip to content

Commit 6c828b8

Browse files
ubettigoleclaude
andauthored
refactor: extract shared request-termination helper (#448)
## Summary Extract the duplicated "transition a request to a terminal state and publish its terminal log" logic into a single TerminateRequest helper in submitqueue/core/request, and route conclude, dlq, cancel, and validate through it. Changes: - Add corerequest.TerminateRequest(ctx, store, registry, requestID, targetState, lastError, metadata) that performs the idempotent 3-way CAS (success / already-target-state / diverged / not-found), publishes the terminal RequestLog, and returns a TerminationResult{Outcome, BeforeState, AfterState} plus a separate error. Version arithmetic stays caller-side and storage.ErrVersionMismatch is passed through as retryable. - Replace terminalStateToStatus's switch with a terminalStatusByState map lookup. - conclude: reconcile each batch member through the helper, keeping per-outcome logs and metrics at the call site. A request referenced by the batch but missing from the store stays a hard error (retry, eventually DLQ). - dlq: failRequest now delegates to the helper, keeping its NotFound tolerance and its reconcile/divergence log messages. - cancel: cancelRequest delegates to the helper (Cancelling to Cancelled); the helper's re-read reports divergence cleanly instead of a spurious version-mismatch retry. - validate: on an expected rejection (duplicate detected or custom validator failure), terminate the request to Error and ack instead of dead-lettering it; genuine infra failures still return errors so they go to the DLQ. - Add table-driven tests for TerminateRequest and update the conclude, cancel, and validate tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> ## Test Plan Unit tests ## Issues --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e00c689 commit 6c828b8

11 files changed

Lines changed: 668 additions & 144 deletions

File tree

submitqueue/core/request/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ go_library(
66
"log.go",
77
"materializer.go",
88
"request.go",
9+
"terminate.go",
910
],
1011
importpath = "github.com/uber/submitqueue/submitqueue/core/request",
1112
visibility = ["//visibility:public"],
@@ -24,6 +25,7 @@ go_test(
2425
"log_test.go",
2526
"materializer_test.go",
2627
"request_test.go",
28+
"terminate_test.go",
2729
],
2830
embed = [":go_default_library"],
2931
deps = [
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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 request
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
22+
"github.com/uber/submitqueue/platform/consumer"
23+
"github.com/uber/submitqueue/submitqueue/entity"
24+
"github.com/uber/submitqueue/submitqueue/extension/storage"
25+
)
26+
27+
// TerminationOutcome describes what TerminateRequest did to the request.
28+
// The zero value (TerminationOutcomeUnknown) is only produced alongside a non-nil error.
29+
type TerminationOutcome int
30+
31+
const (
32+
// TerminationOutcomeUnknown is the zero value, produced only when TerminateRequest also returns a non-nil error.
33+
TerminationOutcomeUnknown TerminationOutcome = iota
34+
// TerminationOutcomeSuccess means the request was transitioned from a non-terminal
35+
// state to the target terminal state and a terminal log entry was published.
36+
TerminationOutcomeSuccess
37+
// TerminationOutcomeAlreadyInTargetState means the request was already in the target terminal
38+
// state. No state write occurred, but the terminal log entry was re-published to
39+
// repair a possible prior attempt that wrote the state but failed before publishing.
40+
TerminationOutcomeAlreadyInTargetState
41+
// TerminationOutcomeDiverged means the request had already reached a different terminal state -
42+
// a concurrent path won the race and owns the terminal log for the state it wrote.
43+
// Nothing was written or published.
44+
TerminationOutcomeDiverged
45+
// TerminationOutcomeNotFound means the request does not exist. Nothing was done.
46+
TerminationOutcomeNotFound
47+
)
48+
49+
// TerminationResult reports what TerminateRequest observed and did, so callers can
50+
// log and emit metrics for the outcome at their own level and granularity without re-fetching the request.
51+
// It is returned alongside a separate error:
52+
// TerminationResult describes the expected variation (reconciled / already-terminal / diverged / not-found).
53+
// Error signals an infrastructure failure.
54+
type TerminationResult struct {
55+
// Outcome is what happened to the request.
56+
Outcome TerminationOutcome
57+
// BeforeState is the request's state as observed before any write.
58+
// On a divergence it is the (different) terminal state the request actually reached.
59+
// On a success it is the prior non-terminal state.
60+
// Empty when the request was not found or the call failed.
61+
BeforeState entity.RequestState
62+
// AfterState is the request's state after the operation.
63+
// Equal to the target state on success and already-terminal.
64+
// Equal to BeforeState on divergence.
65+
// Empty when the request was not found or the call failed.
66+
AfterState entity.RequestState
67+
}
68+
69+
// TerminateRequest transitions a request to the given terminal state and publishes
70+
// the corresponding terminal log entry, idempotently under at-least-once delivery.
71+
// It is the shared primitive behind concluding a batch's requests, dead-letter
72+
// reconciliation, and rejecting an invalid request at validation time.
73+
//
74+
// targetState must be one of the terminal request states (Landed, Error, Cancelled).
75+
// The write follows the immutability / optimistic-locking contract:
76+
// caller-owned version arithmetic (newVersion = version+1) guards a pure
77+
// conditional store write, and the in-memory version is only advanced after the store call succeeds.
78+
//
79+
// Idempotency has three shapes, reported via TerminationResult.Outcome:
80+
// - the request is already in targetState: the state write is skipped but the
81+
// terminal log is re-published (TerminationOutcomeAlreadyInTargetState);
82+
// - the request is in a different terminal state: nothing is written or
83+
// published, since the other writer owns that state's terminal log
84+
// (TerminationOutcomeDiverged);
85+
// - the request is not found: nothing is done (TerminationOutcomeNotFound).
86+
//
87+
// The caller owns all logging and metrics — TerminationResult carries the state context needed to do so.
88+
// lastError and metadata are attached to the published RequestLog for diagnosis.
89+
//
90+
// A storage.ErrVersionMismatch from the conditional write is returned as-is (it is
91+
// intrinsically retryable) so the caller's next attempt re-reads and re-evaluates.
92+
func TerminateRequest(
93+
ctx context.Context,
94+
store storage.Storage,
95+
registry consumer.TopicRegistry,
96+
requestID string,
97+
targetState entity.RequestState,
98+
lastError string,
99+
metadata map[string]string,
100+
) (TerminationResult, error) {
101+
status, err := terminalStateToStatus(targetState)
102+
if err != nil {
103+
return TerminationResult{}, err
104+
}
105+
106+
request, err := store.GetRequestStore().Get(ctx, requestID)
107+
if err != nil {
108+
if errors.Is(err, storage.ErrNotFound) {
109+
return TerminationResult{Outcome: TerminationOutcomeNotFound}, nil
110+
}
111+
return TerminationResult{}, fmt.Errorf("failed to get request %s: %w", requestID, err)
112+
}
113+
114+
// logVersion is the request version reflected in the published terminal log.
115+
// It stays at the current version on the idempotent same-state path and
116+
// advances to the new version only after a successful reconciling write.
117+
logVersion := request.Version
118+
outcome := TerminationOutcomeSuccess
119+
switch {
120+
case request.State == targetState:
121+
// Idempotent retry: a prior attempt already wrote the terminal state.
122+
// Skip the CAS and fall through to re-publish the terminal log.
123+
outcome = TerminationOutcomeAlreadyInTargetState
124+
case entity.IsRequestStateTerminal(request.State):
125+
// Divergent terminal state — a concurrent path reached terminal first
126+
// and owns the terminal log entry for the state it actually wrote.
127+
return TerminationResult{
128+
Outcome: TerminationOutcomeDiverged,
129+
BeforeState: request.State,
130+
AfterState: request.State,
131+
}, nil
132+
default:
133+
newVersion := request.Version + 1
134+
if err := store.GetRequestStore().UpdateState(ctx, requestID, request.Version, newVersion, targetState); err != nil {
135+
return TerminationResult{}, fmt.Errorf("failed to update request %s state to %s: %w", requestID, targetState, err)
136+
}
137+
logVersion = newVersion
138+
}
139+
140+
logEntry := entity.NewRequestLog(requestID, status, logVersion, lastError, metadata)
141+
if err := PublishLog(ctx, registry, logEntry, requestID); err != nil {
142+
return TerminationResult{}, fmt.Errorf("failed to publish request log for %s: %w", requestID, err)
143+
}
144+
145+
return TerminationResult{
146+
Outcome: outcome,
147+
BeforeState: request.State,
148+
AfterState: targetState,
149+
}, nil
150+
}
151+
152+
// terminalStatusByState maps each terminal request state to the customer-facing status published on the terminal log.
153+
// A state absent from this map is not a valid termination target.
154+
var terminalStatusByState = map[entity.RequestState]entity.RequestStatus{
155+
entity.RequestStateLanded: entity.RequestStatusLanded,
156+
entity.RequestStateError: entity.RequestStatusError,
157+
entity.RequestStateCancelled: entity.RequestStatusCancelled,
158+
}
159+
160+
// terminalStateToStatus maps a terminal request state to the corresponding customer-facing log status.
161+
// It rejects non-terminal states so callers cannot terminate a request into a state that is not final.
162+
func terminalStateToStatus(state entity.RequestState) (entity.RequestStatus, error) {
163+
status, ok := terminalStatusByState[state]
164+
if !ok {
165+
return entity.RequestStatusUnknown, fmt.Errorf("non-terminal request state: %s", state)
166+
}
167+
return status, nil
168+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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 request
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
25+
"github.com/uber/submitqueue/platform/consumer"
26+
queuemock "github.com/uber/submitqueue/platform/extension/messagequeue/mock"
27+
"github.com/uber/submitqueue/submitqueue/core/topickey"
28+
"github.com/uber/submitqueue/submitqueue/entity"
29+
"github.com/uber/submitqueue/submitqueue/extension/storage"
30+
storagemock "github.com/uber/submitqueue/submitqueue/extension/storage/mock"
31+
"go.uber.org/mock/gomock"
32+
)
33+
34+
// recordingRegistry returns a registry whose publisher appends every published
35+
// request log to *logs and returns publishErr. It lets tests assert both that a
36+
// terminal log was (or was not) published and what version/status it carried.
37+
func recordingRegistry(t *testing.T, ctrl *gomock.Controller, publishErr error) (consumer.TopicRegistry, *[]entity.RequestLog) {
38+
t.Helper()
39+
logs := &[]entity.RequestLog{}
40+
mockPub := queuemock.NewMockPublisher(ctrl)
41+
mockPub.EXPECT().Publish(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
42+
func(_ context.Context, _ string, msg entityqueue.Message) error {
43+
log, err := entity.RequestLogFromBytes(msg.Payload)
44+
require.NoError(t, err)
45+
*logs = append(*logs, log)
46+
return publishErr
47+
},
48+
).AnyTimes()
49+
50+
mockQ := queuemock.NewMockQueue(ctrl)
51+
mockQ.EXPECT().Publisher().Return(mockPub).AnyTimes()
52+
53+
registry, err := consumer.NewTopicRegistry(
54+
[]consumer.TopicConfig{{Key: topickey.TopicKeyLog, Name: "log", Queue: mockQ}},
55+
)
56+
require.NoError(t, err)
57+
return registry, logs
58+
}
59+
60+
func TestTerminateRequest(t *testing.T) {
61+
const requestID = "q/1"
62+
63+
validated := entity.Request{ID: requestID, Queue: "q", State: entity.RequestStateValidated, Version: 3}
64+
65+
testCases := map[string]struct {
66+
targetState entity.RequestState
67+
lastError string
68+
metadata map[string]string
69+
mockFunc func(rs *storagemock.MockRequestStore)
70+
publishErr error
71+
wantResult TerminationResult
72+
errMsg string
73+
errIs error
74+
// wantLog, when non-nil, asserts the single published terminal log.
75+
wantLog func(t *testing.T, log entity.RequestLog)
76+
}{
77+
"non-terminal target rejected": {
78+
targetState: entity.RequestStateValidated,
79+
mockFunc: func(rs *storagemock.MockRequestStore) {},
80+
wantResult: TerminationResult{Outcome: TerminationOutcomeUnknown},
81+
errMsg: "non-terminal request state: validated",
82+
},
83+
"request not found": {
84+
targetState: entity.RequestStateError,
85+
mockFunc: func(rs *storagemock.MockRequestStore) {
86+
rs.EXPECT().Get(gomock.Any(), requestID).Return(entity.Request{}, storage.ErrNotFound)
87+
},
88+
wantResult: TerminationResult{Outcome: TerminationOutcomeNotFound},
89+
},
90+
"get infra error": {
91+
targetState: entity.RequestStateError,
92+
mockFunc: func(rs *storagemock.MockRequestStore) {
93+
rs.EXPECT().Get(gomock.Any(), requestID).Return(entity.Request{}, fmt.Errorf("db down"))
94+
},
95+
wantResult: TerminationResult{Outcome: TerminationOutcomeUnknown},
96+
errMsg: "failed to get request q/1: db down",
97+
},
98+
"reconciled from non-terminal state": {
99+
targetState: entity.RequestStateError,
100+
lastError: "boom",
101+
metadata: map[string]string{"source": "validate"},
102+
mockFunc: func(rs *storagemock.MockRequestStore) {
103+
rs.EXPECT().Get(gomock.Any(), requestID).Return(validated, nil)
104+
rs.EXPECT().UpdateState(gomock.Any(), requestID, int32(3), int32(4), entity.RequestStateError).Return(nil)
105+
},
106+
wantResult: TerminationResult{
107+
Outcome: TerminationOutcomeSuccess,
108+
BeforeState: entity.RequestStateValidated,
109+
AfterState: entity.RequestStateError,
110+
},
111+
wantLog: func(t *testing.T, log entity.RequestLog) {
112+
assert.Equal(t, entity.RequestStatusError, log.Status)
113+
assert.Equal(t, int32(4), log.RequestVersion)
114+
assert.Equal(t, "boom", log.LastError)
115+
assert.Equal(t, "validate", log.Metadata["source"])
116+
},
117+
},
118+
"already in target terminal state republishes log": {
119+
targetState: entity.RequestStateError,
120+
mockFunc: func(rs *storagemock.MockRequestStore) {
121+
already := entity.Request{ID: requestID, State: entity.RequestStateError, Version: 5}
122+
rs.EXPECT().Get(gomock.Any(), requestID).Return(already, nil)
123+
},
124+
wantResult: TerminationResult{
125+
Outcome: TerminationOutcomeAlreadyInTargetState,
126+
BeforeState: entity.RequestStateError,
127+
AfterState: entity.RequestStateError,
128+
},
129+
wantLog: func(t *testing.T, log entity.RequestLog) {
130+
assert.Equal(t, entity.RequestStatusError, log.Status)
131+
assert.Equal(t, int32(5), log.RequestVersion)
132+
},
133+
},
134+
"already in target terminal state returns republish error": {
135+
targetState: entity.RequestStateError,
136+
mockFunc: func(rs *storagemock.MockRequestStore) {
137+
already := entity.Request{ID: requestID, State: entity.RequestStateError, Version: 5}
138+
rs.EXPECT().Get(gomock.Any(), requestID).Return(already, nil)
139+
},
140+
publishErr: fmt.Errorf("connection refused"),
141+
wantResult: TerminationResult{Outcome: TerminationOutcomeUnknown},
142+
errMsg: "failed to publish request log for q/1",
143+
},
144+
"diverged terminal state is left untouched": {
145+
targetState: entity.RequestStateError,
146+
mockFunc: func(rs *storagemock.MockRequestStore) {
147+
landed := entity.Request{ID: requestID, State: entity.RequestStateLanded, Version: 7}
148+
rs.EXPECT().Get(gomock.Any(), requestID).Return(landed, nil)
149+
},
150+
wantResult: TerminationResult{
151+
Outcome: TerminationOutcomeDiverged,
152+
BeforeState: entity.RequestStateLanded,
153+
AfterState: entity.RequestStateLanded,
154+
},
155+
},
156+
"update version mismatch returned as-is": {
157+
targetState: entity.RequestStateError,
158+
mockFunc: func(rs *storagemock.MockRequestStore) {
159+
rs.EXPECT().Get(gomock.Any(), requestID).Return(validated, nil)
160+
rs.EXPECT().UpdateState(gomock.Any(), requestID, int32(3), int32(4), entity.RequestStateError).Return(storage.ErrVersionMismatch)
161+
},
162+
wantResult: TerminationResult{Outcome: TerminationOutcomeUnknown},
163+
errMsg: "version mismatch",
164+
errIs: storage.ErrVersionMismatch,
165+
},
166+
"publish error after reconcile": {
167+
targetState: entity.RequestStateError,
168+
mockFunc: func(rs *storagemock.MockRequestStore) {
169+
rs.EXPECT().Get(gomock.Any(), requestID).Return(validated, nil)
170+
rs.EXPECT().UpdateState(gomock.Any(), requestID, int32(3), int32(4), entity.RequestStateError).Return(nil)
171+
},
172+
publishErr: fmt.Errorf("connection refused"),
173+
wantResult: TerminationResult{Outcome: TerminationOutcomeUnknown},
174+
errMsg: "failed to publish request log for q/1",
175+
},
176+
}
177+
178+
for name, tc := range testCases {
179+
t.Run(name, func(t *testing.T) {
180+
ctrl := gomock.NewController(t)
181+
requestStore := storagemock.NewMockRequestStore(ctrl)
182+
store := storagemock.NewMockStorage(ctrl)
183+
store.EXPECT().GetRequestStore().Return(requestStore).AnyTimes()
184+
tc.mockFunc(requestStore)
185+
186+
registry, logs := recordingRegistry(t, ctrl, tc.publishErr)
187+
188+
res, err := TerminateRequest(context.Background(), store, registry, requestID, tc.targetState, tc.lastError, tc.metadata)
189+
190+
assert.Equal(t, tc.wantResult, res)
191+
if tc.errMsg != "" {
192+
assert.ErrorContains(t, err, tc.errMsg)
193+
} else {
194+
assert.NoError(t, err)
195+
}
196+
if tc.errIs != nil {
197+
assert.ErrorIs(t, err, tc.errIs)
198+
}
199+
200+
if tc.wantLog != nil {
201+
require.Len(t, *logs, 1)
202+
tc.wantLog(t, (*logs)[0])
203+
} else if tc.publishErr == nil {
204+
assert.Empty(t, *logs)
205+
}
206+
})
207+
}
208+
}

0 commit comments

Comments
 (0)