Skip to content

Commit 5a2e42a

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 44c99ba commit 5a2e42a

22 files changed

Lines changed: 366 additions & 172 deletions

File tree

submitqueue/core/request/terminate.go

Lines changed: 6 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,12 @@ 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+
request.State = targetState
136+
if err := store.GetRequestStore().Update(ctx, request, 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+
request.Version = newVersion
140+
logVersion = request.Version
138141
}
139142

140143
logEntry := entity.NewRequestLog(requestID, status, logVersion, lastError, metadata)
@@ -144,7 +147,7 @@ func TerminateRequest(
144147

145148
return TerminationResult{
146149
Outcome: outcome,
147-
BeforeState: request.State,
150+
BeforeState: beforeState,
148151
AfterState: targetState,
149152
}, nil
150153
}

submitqueue/core/request/terminate_test.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ import (
3131
"go.uber.org/mock/gomock"
3232
)
3333

34+
func requestWithState(request entity.Request, state entity.RequestState) entity.Request {
35+
request.State = state
36+
return request
37+
}
38+
3439
// recordingRegistry returns a registry whose publisher appends every published
3540
// request log to *logs and returns publishErr. It lets tests assert both that a
3641
// terminal log was (or was not) published and what version/status it carried.
@@ -61,6 +66,7 @@ func TestTerminateRequest(t *testing.T) {
6166
const requestID = "q/1"
6267

6368
validated := entity.Request{ID: requestID, Queue: "q", State: entity.RequestStateValidated, Version: 3}
69+
originalValidated := validated
6470

6571
testCases := map[string]struct {
6672
targetState entity.RequestState
@@ -101,7 +107,7 @@ func TestTerminateRequest(t *testing.T) {
101107
metadata: map[string]string{"source": "validate"},
102108
mockFunc: func(rs *storagemock.MockRequestStore) {
103109
rs.EXPECT().Get(gomock.Any(), requestID).Return(validated, nil)
104-
rs.EXPECT().UpdateState(gomock.Any(), requestID, int32(3), int32(4), entity.RequestStateError).Return(nil)
110+
rs.EXPECT().Update(gomock.Any(), requestWithState(validated, entity.RequestStateError), int32(3), int32(4)).Return(nil)
105111
},
106112
wantResult: TerminationResult{
107113
Outcome: TerminationOutcomeSuccess,
@@ -157,7 +163,7 @@ func TestTerminateRequest(t *testing.T) {
157163
targetState: entity.RequestStateError,
158164
mockFunc: func(rs *storagemock.MockRequestStore) {
159165
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)
166+
rs.EXPECT().Update(gomock.Any(), requestWithState(validated, entity.RequestStateError), int32(3), int32(4)).Return(storage.ErrVersionMismatch)
161167
},
162168
wantResult: TerminationResult{Outcome: TerminationOutcomeUnknown},
163169
errMsg: "version mismatch",
@@ -167,7 +173,7 @@ func TestTerminateRequest(t *testing.T) {
167173
targetState: entity.RequestStateError,
168174
mockFunc: func(rs *storagemock.MockRequestStore) {
169175
rs.EXPECT().Get(gomock.Any(), requestID).Return(validated, nil)
170-
rs.EXPECT().UpdateState(gomock.Any(), requestID, int32(3), int32(4), entity.RequestStateError).Return(nil)
176+
rs.EXPECT().Update(gomock.Any(), requestWithState(validated, entity.RequestStateError), int32(3), int32(4)).Return(nil)
171177
},
172178
publishErr: fmt.Errorf("connection refused"),
173179
wantResult: TerminationResult{Outcome: TerminationOutcomeUnknown},
@@ -187,6 +193,7 @@ func TestTerminateRequest(t *testing.T) {
187193

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

196+
assert.Equal(t, originalValidated, validated)
190197
assert.Equal(t, tc.wantResult, res)
191198
if tc.errMsg != "" {
192199
assert.ErrorContains(t, err, tc.errMsg)

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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
190190
// state, so it would CAS the request from Cancelled back to Landed, silently
191191
// undoing the user's cancel.
192192
//
193-
// The CAS below collapses that window. Whichever of batch.UpdateState(...,
193+
// The CAS below collapses that window. Whichever of request.Update(...,
194194
// RequestStateBatched) and cancel.markCancelling(... RequestStateCancelling)
195195
// reaches storage first wins; the loser sees storage.ErrVersionMismatch:
196196
// - If cancel won: this CAS fails. We ack the message (cancel will drive R
@@ -221,7 +221,8 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
221221
// (request cancelled) is still correct — the orphan batch just gets
222222
// reconciled by conclude as if it had no requests to act on.
223223
newRequestVersion := request.Version + 1
224-
if err := c.store.GetRequestStore().UpdateState(ctx, request.ID, request.Version, newRequestVersion, entity.RequestStateBatched); err != nil {
224+
request.State = entity.RequestStateBatched
225+
if err := c.store.GetRequestStore().Update(ctx, request, request.Version, newRequestVersion); err != nil {
225226
// ErrVersionMismatch == cancel (or another writer) advanced R first. Ack
226227
// the message: there is nothing for us to do, and retrying would not help
227228
// since the new state of R is now visible to the cancel pipeline.
@@ -238,7 +239,6 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
238239
return fmt.Errorf("failed to claim request %s for batch %s: %w", request.ID, batch.ID, err)
239240
}
240241
request.Version = newRequestVersion
241-
request.State = entity.RequestStateBatched
242242

243243
// Persist the batch before creating references to it. A Creating batch is not eligible for dependency analysis or normal processing.
244244
if err := c.store.GetBatchStore().Create(ctx, batch); err != nil {

0 commit comments

Comments
 (0)