Skip to content

Commit 3bd96d9

Browse files
committed
refactor(storage): replace request state updates
Persist complete Request entities through guarded updates, migrate callers to candidate copies, and document full-entity replacement semantics. Jira Issues CODEM-204
1 parent de6ac94 commit 3bd96d9

24 files changed

Lines changed: 345 additions & 164 deletions

File tree

submitqueue/core/request/terminate.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ func TerminateRequest(
114114
// logVersion is the request version reflected in the published terminal log.
115115
// It stays at the current version on the idempotent same-state path and
116116
// advances to the new version only after a successful reconciling write.
117+
beforeState := request.State
117118
logVersion := request.Version
118119
outcome := TerminationOutcomeSuccess
119120
switch {
@@ -131,10 +132,13 @@ func TerminateRequest(
131132
}, nil
132133
default:
133134
newVersion := request.Version + 1
134-
if err := store.GetRequestStore().UpdateState(ctx, requestID, request.Version, newVersion, targetState); err != nil {
135+
updatedRequest := request.WithState(targetState)
136+
if err := store.GetRequestStore().Update(ctx, updatedRequest, request.Version, newVersion); err != nil {
135137
return TerminationResult{}, fmt.Errorf("failed to update request %s state to %s: %w", requestID, targetState, err)
136138
}
137-
logVersion = newVersion
139+
updatedRequest.Version = newVersion
140+
request = updatedRequest
141+
logVersion = request.Version
138142
}
139143

140144
logEntry := entity.NewRequestLog(requestID, status, logVersion, lastError, metadata)
@@ -144,7 +148,7 @@ func TerminateRequest(
144148

145149
return TerminationResult{
146150
Outcome: outcome,
147-
BeforeState: request.State,
151+
BeforeState: beforeState,
148152
AfterState: targetState,
149153
}, nil
150154
}

submitqueue/core/request/terminate_test.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func TestTerminateRequest(t *testing.T) {
6161
const requestID = "q/1"
6262

6363
validated := entity.Request{ID: requestID, Queue: "q", State: entity.RequestStateValidated, Version: 3}
64+
originalValidated := validated
6465

6566
testCases := map[string]struct {
6667
targetState entity.RequestState
@@ -101,7 +102,7 @@ func TestTerminateRequest(t *testing.T) {
101102
metadata: map[string]string{"source": "validate"},
102103
mockFunc: func(rs *storagemock.MockRequestStore) {
103104
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+
rs.EXPECT().Update(gomock.Any(), validated.WithState(entity.RequestStateError), int32(3), int32(4)).Return(nil)
105106
},
106107
wantResult: TerminationResult{
107108
Outcome: TerminationOutcomeSuccess,
@@ -157,7 +158,7 @@ func TestTerminateRequest(t *testing.T) {
157158
targetState: entity.RequestStateError,
158159
mockFunc: func(rs *storagemock.MockRequestStore) {
159160
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+
rs.EXPECT().Update(gomock.Any(), validated.WithState(entity.RequestStateError), int32(3), int32(4)).Return(storage.ErrVersionMismatch)
161162
},
162163
wantResult: TerminationResult{Outcome: TerminationOutcomeUnknown},
163164
errMsg: "version mismatch",
@@ -167,7 +168,7 @@ func TestTerminateRequest(t *testing.T) {
167168
targetState: entity.RequestStateError,
168169
mockFunc: func(rs *storagemock.MockRequestStore) {
169170
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+
rs.EXPECT().Update(gomock.Any(), validated.WithState(entity.RequestStateError), int32(3), int32(4)).Return(nil)
171172
},
172173
publishErr: fmt.Errorf("connection refused"),
173174
wantResult: TerminationResult{Outcome: TerminationOutcomeUnknown},
@@ -187,6 +188,7 @@ func TestTerminateRequest(t *testing.T) {
187188

188189
res, err := TerminateRequest(context.Background(), store, registry, requestID, tc.targetState, tc.lastError, tc.metadata)
189190

191+
assert.Equal(t, originalValidated, validated)
190192
assert.Equal(t, tc.wantResult, res)
191193
if tc.errMsg != "" {
192194
assert.ErrorContains(t, err, tc.errMsg)

submitqueue/entity/request.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ type Request struct {
9696
Version int32 `json:"version"`
9797
}
9898

99+
// WithState returns a shallow copy of the request with State replaced.
100+
// Slice fields continue to share their backing arrays.
101+
func (r Request) WithState(state RequestState) Request {
102+
r.State = state
103+
return r
104+
}
105+
99106
// ToBytes serializes the Request to JSON bytes for queue message payload.
100107
func (r Request) ToBytes() ([]byte, error) {
101108
return json.Marshal(r)

submitqueue/entity/request_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,25 @@ func TestRequestFromBytes_EmptyData(t *testing.T) {
9696
assert.Equal(t, int32(0), req.Version)
9797
}
9898

99+
func TestRequest_WithState(t *testing.T) {
100+
request := Request{
101+
ID: "queueA/1",
102+
Queue: "queueA",
103+
State: RequestStateStarted,
104+
Version: 1,
105+
}
106+
107+
updated := request.WithState(RequestStateValidated)
108+
109+
assert.Equal(t, RequestStateStarted, request.State)
110+
assert.Equal(t, Request{
111+
ID: request.ID,
112+
Queue: request.Queue,
113+
State: RequestStateValidated,
114+
Version: request.Version,
115+
}, updated)
116+
}
117+
99118
func TestIsRequestStateTerminal(t *testing.T) {
100119
tests := []struct {
101120
state RequestState

submitqueue/extension/storage/README.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,31 @@ Pluggable persistence interfaces for SubmitQueue entities (requests, batches, de
66

77
Entities that support concurrent mutation carry an `int32 Version` field. Updates are conditional on the version: the write only succeeds if the persisted version matches the caller's expected version. On mismatch, the implementation returns `storage.ErrVersionMismatch`, which is declared as a retryable infrastructure error so callers can return it without reclassifying it.
88

9-
**Version arithmetic is owned by the controller, not the store.** Update methods take both `oldVersion` (the where-clause guard) and `newVersion` (the value to write):
9+
**Updates replace every non-primary-key field.** Callers must pass a complete authoritative entity loaded from storage or constructed with every persisted field; sparse patch entities can clear unrelated columns. The primary key identifies the row and is not rewritten.
10+
11+
**Version arithmetic is owned by the controller, not the store.** Versioned update methods take a complete entity plus both `oldVersion` (the where-clause guard) and `newVersion` (the value to write):
1012

1113
```go
12-
UpdateState(ctx, id, oldVersion, newVersion int32, newState entity.RequestState) error
14+
Update(ctx, request entity.Request, oldVersion, newVersion int32) error
1315
```
1416

15-
The store performs a pure conditional write — it does not compute `oldVersion + 1` internally. This keeps the in-memory entity and the persisted row in sync without the storage layer mutating values the caller didn't supply.
17+
The store writes `newVersion` rather than the entity's current `Version` and performs a pure conditional write — it does not compute `oldVersion + 1` internally. This keeps the in-memory entity and the persisted row in sync without the storage layer mutating values the caller didn't supply.
1618

1719
### Caller pattern
1820

1921
```go
20-
newVersion := entity.Version + 1
21-
if err := store.UpdateState(ctx, entity.ID, entity.Version, newVersion, newState); err != nil {
22-
return err // entity.Version unchanged on failure — safe to retry
22+
oldVersion := request.Version
23+
newVersion := oldVersion + 1
24+
updated := request
25+
updated.State = newState
26+
if err := store.Update(ctx, updated, oldVersion, newVersion); err != nil {
27+
return err // request remains unchanged on failure — safe to retry
2328
}
24-
entity.Version = newVersion // only after the write succeeded
29+
updated.Version = newVersion
30+
request = updated // only after the write succeeded
2531
```
2632

27-
The post-success assignment matters whenever the entity is read again later in the same flow. Pre-incrementing in memory before the call is a bug pattern: if the call fails and the caller swallows the error, the in-memory version is now ahead of the database and subsequent updates will fail with `ErrVersionMismatch` for non-obvious reasons.
33+
The candidate-copy pattern keeps the caller-owned entity unchanged if the write fails. Clone slice and map fields before changing their contents so the candidate cannot mutate the original through shared backing storage. The post-success assignment matters whenever the entity is read again later in the same flow. Pre-incrementing in memory before the call is a bug pattern: if the call fails and the caller swallows the error, the in-memory version is now ahead of the database and subsequent updates will fail with `ErrVersionMismatch` for non-obvious reasons.
2834

2935
## Read-after-write consistency
3036

submitqueue/extension/storage/mock/request_store_mock.go

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

submitqueue/extension/storage/mysql/request_store.go

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -93,36 +93,40 @@ func (r *requestStore) Create(ctx context.Context, request entity.Request) (retE
9393
return nil
9494
}
9595

96-
// UpdateState updates the state of a land request to newState and the version to newVersion
97-
// if the current persisted version matches oldVersion. If versions do not match, returns ErrVersionMismatch.
98-
// Version arithmetic is owned by the caller; this is a pure conditional write.
99-
func (r *requestStore) UpdateState(ctx context.Context, id string, oldVersion, newVersion int32, newState entity.RequestState) (retErr error) {
96+
// Update replaces every non-key field of a land request and writes newVersion if the current persisted version matches oldVersion.
97+
// If versions do not match, returns ErrVersionMismatch. Version arithmetic is owned by the caller; this is a pure conditional write.
98+
func (r *requestStore) Update(ctx context.Context, request entity.Request, oldVersion, newVersion int32) (retErr error) {
10099
op := metrics.Begin(r.scope, "update_state", metrics.StorageLatencyBuckets)
101100
defer func() { op.Complete(retErr) }()
102101

102+
changeURIsJSON, err := json.Marshal(request.Change.URIs)
103+
if err != nil {
104+
return fmt.Errorf("failed to marshal change URIs for request id=%s: %w", request.ID, err)
105+
}
106+
103107
result, err := r.db.ExecContext(ctx,
104-
"UPDATE request SET state = ?, version = ? WHERE id = ? AND version = ?",
105-
newState, newVersion, id, oldVersion,
108+
"UPDATE request SET queue = ?, change_uri = ?, land_strategy = ?, state = ?, version = ? WHERE id = ? AND version = ?",
109+
request.Queue, changeURIsJSON, request.LandStrategy, request.State, newVersion, request.ID, oldVersion,
106110
)
107111
if err != nil {
108112
return fmt.Errorf(
109-
"failed to update request state for id=%q oldVersion=%d newVersion=%d newState=%v: %w",
110-
id, oldVersion, newVersion, newState, err,
113+
"failed to update request for id=%q oldVersion=%d newVersion=%d: %w",
114+
request.ID, oldVersion, newVersion, err,
111115
)
112116
}
113117

114118
rowsAffected, err := result.RowsAffected()
115119
if err != nil {
116120
return fmt.Errorf(
117-
"failed to get rows affected from update for id=%q oldVersion=%d newVersion=%d newState=%v: %w",
118-
id, oldVersion, newVersion, newState, err,
121+
"failed to get rows affected from update for id=%q oldVersion=%d newVersion=%d: %w",
122+
request.ID, oldVersion, newVersion, err,
119123
)
120124
}
121125

122126
if rowsAffected != 1 {
123127
return fmt.Errorf(
124-
"version mismatch for request update: id=%q expected_version=%d newState=%v: %w",
125-
id, oldVersion, newState, storage.ErrVersionMismatch,
128+
"version mismatch for request update: id=%q expected_version=%d: %w",
129+
request.ID, oldVersion, storage.ErrVersionMismatch,
126130
)
127131
}
128132

submitqueue/extension/storage/mysql/request_store_test.go

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -198,53 +198,91 @@ func TestRequestStore_Create(t *testing.T) {
198198
}
199199
}
200200

201-
func TestRequestStore_UpdateState(t *testing.T) {
202-
const id = "monorepo/1"
201+
func TestRequestStore_Update(t *testing.T) {
203202
const oldVersion, newVersion = int32(1), int32(2)
204-
const newState = entity.RequestStateValidated
203+
request := entity.Request{
204+
ID: "monorepo/1",
205+
Queue: "monorepo-updated",
206+
Change: change.Change{URIs: []string{"github://github.example.com/uber/submitqueue/pull/456/cafebabe"}},
207+
LandStrategy: mergestrategy.MergeStrategySquashRebase,
208+
State: entity.RequestStateValidated,
209+
Version: oldVersion,
210+
}
211+
changeURIsJSON, err := json.Marshal(request.Change.URIs)
212+
require.NoError(t, err)
205213

206214
tests := []struct {
207215
name string
216+
request entity.Request
208217
setup func(mock sqlmock.Sqlmock)
209218
wantErr bool
210219
wantErrIs error
211220
}{
212221
{
213-
name: "success",
222+
name: "success",
223+
request: request,
214224
setup: func(mock sqlmock.Sqlmock) {
215225
mock.ExpectExec("UPDATE request").
216-
WithArgs(newState, newVersion, id, oldVersion).
226+
WithArgs(request.Queue, changeURIsJSON, request.LandStrategy, request.State, newVersion, request.ID, oldVersion).
217227
WillReturnResult(sqlmock.NewResult(0, 1))
218228
},
219229
},
220230
{
221-
name: "version mismatch",
231+
name: "version mismatch",
232+
request: request,
222233
setup: func(mock sqlmock.Sqlmock) {
223234
mock.ExpectExec("UPDATE request").
224-
WithArgs(newState, newVersion, id, oldVersion).
235+
WithArgs(request.Queue, changeURIsJSON, request.LandStrategy, request.State, newVersion, request.ID, oldVersion).
225236
WillReturnResult(sqlmock.NewResult(0, 0))
226237
},
227238
wantErr: true,
228239
wantErrIs: storage.ErrVersionMismatch,
229240
},
230241
{
231-
name: "exec error",
242+
name: "exec error",
243+
request: request,
232244
setup: func(mock sqlmock.Sqlmock) {
233245
mock.ExpectExec("UPDATE request").
234-
WithArgs(newState, newVersion, id, oldVersion).
246+
WithArgs(request.Queue, changeURIsJSON, request.LandStrategy, request.State, newVersion, request.ID, oldVersion).
235247
WillReturnError(fmt.Errorf("connection reset"))
236248
},
237249
wantErr: true,
238250
},
239251
{
240-
name: "rows affected error",
252+
name: "rows affected error",
253+
request: request,
241254
setup: func(mock sqlmock.Sqlmock) {
242255
mock.ExpectExec("UPDATE request").
243-
WithArgs(newState, newVersion, id, oldVersion).
256+
WithArgs(request.Queue, changeURIsJSON, request.LandStrategy, request.State, newVersion, request.ID, oldVersion).
244257
WillReturnResult(sqlmock.NewErrorResult(fmt.Errorf("driver error")))
245258
},
246259
wantErr: true,
247260
},
261+
{
262+
name: "nil change URIs",
263+
request: entity.Request{ID: request.ID, Queue: request.Queue, LandStrategy: request.LandStrategy, State: request.State, Version: request.Version},
264+
setup: func(mock sqlmock.Sqlmock) {
265+
mock.ExpectExec("UPDATE request").
266+
WithArgs(request.Queue, []byte("null"), request.LandStrategy, request.State, newVersion, request.ID, oldVersion).
267+
WillReturnResult(sqlmock.NewResult(0, 1))
268+
},
269+
},
270+
{
271+
name: "empty change URIs",
272+
request: entity.Request{
273+
ID: request.ID,
274+
Queue: request.Queue,
275+
Change: change.Change{URIs: []string{}},
276+
LandStrategy: request.LandStrategy,
277+
State: request.State,
278+
Version: request.Version,
279+
},
280+
setup: func(mock sqlmock.Sqlmock) {
281+
mock.ExpectExec("UPDATE request").
282+
WithArgs(request.Queue, []byte("[]"), request.LandStrategy, request.State, newVersion, request.ID, oldVersion).
283+
WillReturnResult(sqlmock.NewResult(0, 1))
284+
},
285+
},
248286
}
249287

250288
for _, tt := range tests {
@@ -254,7 +292,7 @@ func TestRequestStore_UpdateState(t *testing.T) {
254292

255293
tt.setup(mock)
256294

257-
err := store.UpdateState(context.Background(), id, oldVersion, newVersion, newState)
295+
err := store.Update(context.Background(), tt.request, oldVersion, newVersion)
258296
if tt.wantErr {
259297
require.Error(t, err)
260298
if tt.wantErrIs != nil {

submitqueue/extension/storage/request_store.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@ type RequestStore interface {
3131
// Returns ErrAlreadyExists if a request with the same ID already exists.
3232
Create(ctx context.Context, request entity.Request) error
3333

34-
// UpdateState updates the state of a land request to newState and the version to newVersion
35-
// if the current persisted version matches oldVersion. If versions do not match, returns ErrVersionMismatch.
36-
// Version arithmetic is owned by the caller; the store performs a pure conditional write.
37-
UpdateState(ctx context.Context, id string, oldVersion, newVersion int32, newState entity.RequestState) error
34+
// Update replaces every non-key field of a land request and writes newVersion if the current persisted version matches oldVersion.
35+
// If versions do not match, returns ErrVersionMismatch. Version arithmetic is owned by the caller; the store performs a pure conditional write.
36+
Update(ctx context.Context, request entity.Request, oldVersion, newVersion int32) error
3837
}

submitqueue/orchestrator/controller/batch/batch.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
221221
// state, so it would CAS the request from Cancelled back to Landed, silently
222222
// undoing the user's cancel.
223223
//
224-
// The CAS below collapses that window. Whichever of batch.UpdateState(...,
224+
// The CAS below collapses that window. Whichever of request.Update(...,
225225
// RequestStateBatched) and cancel.markCancelling(... RequestStateCancelling)
226226
// reaches storage first wins; the loser sees storage.ErrVersionMismatch:
227227
// - If cancel won: this CAS fails. We ack the message (cancel will drive R
@@ -253,7 +253,8 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
253253
// (request cancelled) is still correct — the orphan batch just gets
254254
// reconciled by conclude as if it had no requests to act on.
255255
newRequestVersion := request.Version + 1
256-
if err := c.store.GetRequestStore().UpdateState(ctx, request.ID, request.Version, newRequestVersion, entity.RequestStateBatched); err != nil {
256+
updatedRequest := request.WithState(entity.RequestStateBatched)
257+
if err := c.store.GetRequestStore().Update(ctx, updatedRequest, request.Version, newRequestVersion); err != nil {
257258
// ErrVersionMismatch == cancel (or another writer) advanced R first. Ack
258259
// the message: there is nothing for us to do, and retrying would not help
259260
// since the new state of R is now visible to the cancel pipeline.
@@ -269,8 +270,8 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
269270
metrics.NamedCounter(c.metricsScope, opName, "request_claim_errors", 1)
270271
return fmt.Errorf("failed to claim request %s for batch %s: %w", request.ID, batch.ID, err)
271272
}
272-
request.Version = newRequestVersion
273-
request.State = entity.RequestStateBatched
273+
updatedRequest.Version = newRequestVersion
274+
request = updatedRequest
274275

275276
// Persist batch to storage.
276277
// This is the final operation that concludes the batch creation process. If it fails, BatchDependents will be pointing to a batch id that does not exist.

0 commit comments

Comments
 (0)