Skip to content

Commit 09cfd80

Browse files
authored
refactor(storage): replace build status update with full update (#493)
## Summary Migrate BuildStore callers and MySQL persistence to pass and replace the complete build entity. ## Test Plan Unit tested ## Issues
1 parent 51a084b commit 09cfd80

11 files changed

Lines changed: 68 additions & 51 deletions

File tree

doc/rfc/stovepipe/steps/build.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ Either could be adopted independently: the idempotency token, if a backend that
316316

317317
Plus the `BuildID{ID string}` wire type in `stovepipe/entity` (same "id only travels" convention as `RequestID`, shaped like SubmitQueue's own `entity.BuildID` but not the same Go type — see the [contract sketch](#stovepipe-buildrunner-contract-design-sketch)), wrapping the one runner-assigned id everywhere it appears — `Trigger`'s return, the queue payload, `Status`/`Cancel`'s parameter. `buildsignal` reaches a build by the id carried in its message, and `record` reads the `Request` (whose state carries the build's outcome) rather than a `Build`, so no reverse index from `Request` to its builds is ever needed.
318318

319-
**`BuildStore`** (new, added to the `Storage` aggregator via `GetBuildStore()`), matching stovepipe's existing `RequestStore` conventions — **generic `Update` with caller-owned version arithmetic, not SubmitQueue's field-specific `UpdateStatus`**:
319+
**`BuildStore`** (new, added to the `Storage` aggregator via `GetBuildStore()`), matching stovepipe's existing `RequestStore` conventions — **generic `Update` with caller-owned version arithmetic**:
320320

321321
- `Create(ctx, build entity.Build) error``ErrAlreadyExists` if the id is taken.
322322
- `Get(ctx, id string) (entity.Build, error)``ErrNotFound` if absent.

submitqueue/extension/storage/build_store.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ type BuildStore interface {
3131
// Returns ErrAlreadyExists if a build with the same ID already exists.
3232
Create(ctx context.Context, build entity.Build) error
3333

34-
// UpdateStatus updates the status of a build.
35-
UpdateStatus(ctx context.Context, id string, newStatus entity.BuildStatus) error
34+
// Update replaces all non-key fields of a build.
35+
Update(ctx context.Context, build entity.Build) error
3636
}

submitqueue/extension/storage/mock/build_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/build_store.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,26 +80,26 @@ func (s *buildStore) Create(ctx context.Context, build entity.Build) (retErr err
8080
return nil
8181
}
8282

83-
// UpdateStatus updates the status of a build. Returns ErrNotFound if the build is not found.
84-
func (s *buildStore) UpdateStatus(ctx context.Context, id string, newStatus entity.BuildStatus) (retErr error) {
83+
// Update replaces all non-key fields of a build. Returns ErrNotFound if the build is not found.
84+
func (s *buildStore) Update(ctx context.Context, build entity.Build) (retErr error) {
8585
op := metrics.Begin(s.scope, "update_status", metrics.StorageLatencyBuckets)
8686
defer func() { op.Complete(retErr) }()
8787

8888
result, err := s.db.ExecContext(ctx,
89-
"UPDATE build SET status = ? WHERE id = ?",
90-
newStatus, id,
89+
"UPDATE build SET batch_id = ?, status = ? WHERE id = ?",
90+
build.BatchID, build.Status, build.ID,
9191
)
9292
if err != nil {
93-
return fmt.Errorf("failed to update build status for id=%q newStatus=%v: %w", id, newStatus, err)
93+
return fmt.Errorf("failed to update build entity id=%q: %w", build.ID, err)
9494
}
9595

9696
rowsAffected, err := result.RowsAffected()
9797
if err != nil {
98-
return fmt.Errorf("failed to get rows affected from update for id=%q newStatus=%v: %w", id, newStatus, err)
98+
return fmt.Errorf("failed to get rows affected from update for build entity id=%q: %w", build.ID, err)
9999
}
100100

101101
if rowsAffected != 1 {
102-
return storage.WrapNotFound(fmt.Errorf("build entity id=%s", id))
102+
return storage.WrapNotFound(fmt.Errorf("build entity id=%s", build.ID))
103103
}
104104

105105
return nil

submitqueue/extension/storage/mysql/build_store_test.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -175,9 +175,12 @@ func TestBuildStore_Create(t *testing.T) {
175175
}
176176
}
177177

178-
func TestBuildStore_UpdateStatus(t *testing.T) {
179-
const id = "bk-1001"
180-
const newStatus = entity.BuildStatusSucceeded
178+
func TestBuildStore_Update(t *testing.T) {
179+
build := entity.Build{
180+
ID: "bk-1001",
181+
BatchID: "monorepo/batch/2",
182+
Status: entity.BuildStatusSucceeded,
183+
}
181184

182185
tests := []struct {
183186
name string
@@ -189,15 +192,15 @@ func TestBuildStore_UpdateStatus(t *testing.T) {
189192
name: "success",
190193
setup: func(mock sqlmock.Sqlmock) {
191194
mock.ExpectExec("UPDATE build").
192-
WithArgs(newStatus, id).
195+
WithArgs(build.BatchID, build.Status, build.ID).
193196
WillReturnResult(sqlmock.NewResult(0, 1))
194197
},
195198
},
196199
{
197200
name: "not found",
198201
setup: func(mock sqlmock.Sqlmock) {
199202
mock.ExpectExec("UPDATE build").
200-
WithArgs(newStatus, id).
203+
WithArgs(build.BatchID, build.Status, build.ID).
201204
WillReturnResult(sqlmock.NewResult(0, 0))
202205
},
203206
wantErr: true,
@@ -207,7 +210,7 @@ func TestBuildStore_UpdateStatus(t *testing.T) {
207210
name: "exec error",
208211
setup: func(mock sqlmock.Sqlmock) {
209212
mock.ExpectExec("UPDATE build").
210-
WithArgs(newStatus, id).
213+
WithArgs(build.BatchID, build.Status, build.ID).
211214
WillReturnError(fmt.Errorf("connection reset"))
212215
},
213216
wantErr: true,
@@ -216,7 +219,7 @@ func TestBuildStore_UpdateStatus(t *testing.T) {
216219
name: "rows affected error",
217220
setup: func(mock sqlmock.Sqlmock) {
218221
mock.ExpectExec("UPDATE build").
219-
WithArgs(newStatus, id).
222+
WithArgs(build.BatchID, build.Status, build.ID).
220223
WillReturnResult(sqlmock.NewErrorResult(fmt.Errorf("driver error")))
221224
},
222225
wantErr: true,
@@ -230,7 +233,7 @@ func TestBuildStore_UpdateStatus(t *testing.T) {
230233

231234
tt.setup(mock)
232235

233-
err := store.UpdateStatus(context.Background(), id, newStatus)
236+
err := store.Update(context.Background(), build)
234237
if tt.wantErr {
235238
require.Error(t, err)
236239
if tt.wantErrIs != nil {

submitqueue/orchestrator/controller/build/build.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
141141
}
142142

143143
// Persist the initial Build snapshot so the buildsignal poll loop has a
144-
// row to UpdateStatus against. ErrAlreadyExists is benign — a redelivery
144+
// row to Update against. ErrAlreadyExists is benign — a redelivery
145145
// of this message after a previous successful Create.
146146
if err := c.store.GetBuildStore().Create(ctx, build); err != nil && !errors.Is(err, storage.ErrAlreadyExists) {
147147
metrics.NamedCounter(c.metricsScope, opName, "storage_errors", 1)

submitqueue/orchestrator/controller/build/build_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ func TestController_Process_TriggersWithBaseAndHead(t *testing.T) {
222222
// TestController_Process_BuildStoreAlreadyExistsIsSwallowed covers the
223223
// redelivery case: Create returns ErrAlreadyExists, the controller proceeds
224224
// to publish to buildsignal anyway. The polling loop will pick up the
225-
// existing row via UpdateStatus.
225+
// existing row via Update.
226226
func TestController_Process_BuildStoreAlreadyExistsIsSwallowed(t *testing.T) {
227227
ctrl := gomock.NewController(t)
228228

submitqueue/orchestrator/controller/buildsignal/buildsignal.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ func NewController(
9393
// a delayed message back to this topic when the build is still in flight.
9494
// Returns nil to ack (success), or error to nack/reject.
9595
//
96-
// Error classification: deserialize, Status, UpdateStatus, and the speculate
96+
// Error classification: deserialize, Status, Update, and the speculate
9797
// publish stay non-retryable — they reject straight to DLQ on the first
9898
// failure, where the operational republish path is the recovery mechanism.
9999
// Only the PublishAfter self-reschedule is retryable: it is the poll loop's
@@ -163,38 +163,39 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
163163
return nil
164164
}
165165

166-
build.Status = status
166+
updatedBuild := build
167+
updatedBuild.Status = status
167168

168-
if err := c.store.GetBuildStore().UpdateStatus(ctx, build.ID, status); err != nil {
169+
if err := c.store.GetBuildStore().Update(ctx, updatedBuild); err != nil {
169170
metrics.NamedCounter(c.metricsScope, opName, "storage_errors", 1)
170171
return fmt.Errorf("failed to update status for build %s: %w", build.ID, err)
171172
}
172173

173174
// Re-evaluate the batch state machine with the latest build status.
174-
if err := c.publishBatchID(ctx, topickey.TopicKeySpeculate, build.BatchID, msg.PartitionKey); err != nil {
175+
if err := c.publishBatchID(ctx, topickey.TopicKeySpeculate, updatedBuild.BatchID, msg.PartitionKey); err != nil {
175176
metrics.NamedCounter(c.metricsScope, opName, "publish_errors", 1)
176177
return fmt.Errorf("failed to publish to speculate: %w", err)
177178
}
178179

179180
if status.IsTerminal() {
180181
metrics.NamedCounter(c.metricsScope, opName, "terminal", 1, metrics.NewTag("status", string(status)))
181182
c.logger.Infow("build reached terminal status",
182-
"build_id", build.ID,
183-
"batch_id", build.BatchID,
183+
"build_id", updatedBuild.ID,
184+
"batch_id", updatedBuild.BatchID,
184185
"status", string(status),
185186
)
186187
return nil
187188
}
188189

189190
delayMs := pollDelay(status)
190191
metrics.NamedCounter(c.metricsScope, opName, "rescheduled", 1, metrics.NewTag("status", string(status)))
191-
if err := c.publishBuild(ctx, c.topicKey, build, delayMs); err != nil {
192+
if err := c.publishBuild(ctx, c.topicKey, updatedBuild, delayMs); err != nil {
192193
metrics.NamedCounter(c.metricsScope, opName, "publish_errors", 1)
193194
return fmt.Errorf("failed to re-publish to buildsignal: %w", err)
194195
}
195196

196197
c.logger.Debugw("rescheduled build status poll",
197-
"build_id", build.ID,
198+
"build_id", updatedBuild.ID,
198199
"status", string(status),
199200
"delay_ms", delayMs,
200201
)

submitqueue/orchestrator/controller/buildsignal/buildsignal_test.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,13 @@ func TestController_Process_Terminal(t *testing.T) {
134134
h := newTestHarness(t, ctrl)
135135

136136
build := entity.Build{ID: "b-1", BatchID: "batch-1", Status: entity.BuildStatusAccepted}
137+
updatedBuild := build
138+
updatedBuild.Status = tt.status
137139

138140
h.buildStore.EXPECT().Get(gomock.Any(), build.ID).Return(build, nil)
139141
h.br.EXPECT().Status(gomock.Any(), entity.BuildID{ID: build.ID}).Return(tt.status, entity.BuildMetadata{}, nil)
140142
h.batchStore.EXPECT().Get(gomock.Any(), build.BatchID).Return(entity.Batch{ID: build.BatchID, State: entity.BatchStateSpeculating}, nil)
141-
h.buildStore.EXPECT().UpdateStatus(gomock.Any(), build.ID, tt.status).Return(nil)
143+
h.buildStore.EXPECT().Update(gomock.Any(), updatedBuild).Return(nil)
142144
h.speculatePub.EXPECT().
143145
Publish(gomock.Any(), "speculate", gomock.AssignableToTypeOf(entityqueue.Message{})).
144146
DoAndReturn(func(_ context.Context, _ string, msg entityqueue.Message) error {
@@ -174,11 +176,13 @@ func TestController_Process_NonTerminal(t *testing.T) {
174176
h := newTestHarness(t, ctrl)
175177

176178
build := entity.Build{ID: "b-2", BatchID: "batch-2", Status: entity.BuildStatusAccepted}
179+
updatedBuild := build
180+
updatedBuild.Status = tt.status
177181

178182
h.buildStore.EXPECT().Get(gomock.Any(), build.ID).Return(build, nil)
179183
h.br.EXPECT().Status(gomock.Any(), entity.BuildID{ID: build.ID}).Return(tt.status, entity.BuildMetadata{}, nil)
180184
h.batchStore.EXPECT().Get(gomock.Any(), build.BatchID).Return(entity.Batch{ID: build.BatchID, State: entity.BatchStateSpeculating}, nil)
181-
h.buildStore.EXPECT().UpdateStatus(gomock.Any(), build.ID, tt.status).Return(nil)
185+
h.buildStore.EXPECT().Update(gomock.Any(), updatedBuild).Return(nil)
182186
h.speculatePub.EXPECT().Publish(gomock.Any(), "speculate", gomock.Any()).Return(nil).Times(1)
183187
h.signalPub.EXPECT().
184188
PublishAfter(gomock.Any(), "buildsignal", gomock.AssignableToTypeOf(entityqueue.Message{}), tt.wantDelayMs).
@@ -205,24 +209,26 @@ func TestController_Process_StatusError(t *testing.T) {
205209
h.buildStore.EXPECT().Get(gomock.Any(), build.ID).Return(build, nil)
206210
h.batchStore.EXPECT().Get(gomock.Any(), build.BatchID).Return(entity.Batch{ID: build.BatchID, State: entity.BatchStateSpeculating}, nil)
207211
h.br.EXPECT().Status(gomock.Any(), entity.BuildID{ID: build.ID}).Return(entity.BuildStatusUnknown, nil, errors.New("provider down"))
208-
// No UpdateStatus, no Publish, no PublishAfter expected.
212+
// No Update, no Publish, no PublishAfter expected.
209213

210214
err := h.controller.Process(context.Background(), buildDelivery(t, ctrl, build))
211215
require.Error(t, err)
212216
// Non-retryable: rejects to DLQ on first failure; republish is the recovery path.
213217
assert.False(t, errs.IsRetryable(err))
214218
}
215219

216-
func TestController_Process_UpdateStatusError(t *testing.T) {
220+
func TestController_Process_UpdateError(t *testing.T) {
217221
ctrl := gomock.NewController(t)
218222
h := newTestHarness(t, ctrl)
219223

220224
build := entity.Build{ID: "b-4", BatchID: "batch-4", Status: entity.BuildStatusAccepted}
225+
updatedBuild := build
226+
updatedBuild.Status = entity.BuildStatusRunning
221227

222228
h.buildStore.EXPECT().Get(gomock.Any(), build.ID).Return(build, nil)
223229
h.br.EXPECT().Status(gomock.Any(), entity.BuildID{ID: build.ID}).Return(entity.BuildStatusRunning, nil, nil)
224230
h.batchStore.EXPECT().Get(gomock.Any(), build.BatchID).Return(entity.Batch{ID: build.BatchID, State: entity.BatchStateSpeculating}, nil)
225-
h.buildStore.EXPECT().UpdateStatus(gomock.Any(), build.ID, entity.BuildStatusRunning).
231+
h.buildStore.EXPECT().Update(gomock.Any(), updatedBuild).
226232
Return(errors.New("db unreachable"))
227233
// No Publish / PublishAfter expected after the store failure.
228234

@@ -240,11 +246,13 @@ func TestController_Process_RepublishError(t *testing.T) {
240246
h := newTestHarness(t, ctrl)
241247

242248
build := entity.Build{ID: "b-5", BatchID: "batch-5", Status: entity.BuildStatusAccepted}
249+
updatedBuild := build
250+
updatedBuild.Status = entity.BuildStatusRunning
243251

244252
h.buildStore.EXPECT().Get(gomock.Any(), build.ID).Return(build, nil)
245253
h.br.EXPECT().Status(gomock.Any(), entity.BuildID{ID: build.ID}).Return(entity.BuildStatusRunning, entity.BuildMetadata{}, nil)
246254
h.batchStore.EXPECT().Get(gomock.Any(), build.BatchID).Return(entity.Batch{ID: build.BatchID, State: entity.BatchStateSpeculating}, nil)
247-
h.buildStore.EXPECT().UpdateStatus(gomock.Any(), build.ID, entity.BuildStatusRunning).Return(nil)
255+
h.buildStore.EXPECT().Update(gomock.Any(), updatedBuild).Return(nil)
248256
h.speculatePub.EXPECT().Publish(gomock.Any(), "speculate", gomock.Any()).Return(nil).Times(1)
249257
h.signalPub.EXPECT().
250258
PublishAfter(gomock.Any(), "buildsignal", gomock.Any(), PollDelayRunningMs).
@@ -264,7 +272,7 @@ func TestController_Process_GetError(t *testing.T) {
264272
build := entity.Build{ID: "b-6", BatchID: "batch-6", Status: entity.BuildStatusAccepted}
265273

266274
h.buildStore.EXPECT().Get(gomock.Any(), build.ID).Return(entity.Build{}, errors.New("db unreachable"))
267-
// No Status / UpdateStatus / Publish expected once the load fails.
275+
// No Status / Update / Publish expected once the load fails.
268276

269277
err := h.controller.Process(context.Background(), buildDelivery(t, ctrl, build))
270278
require.Error(t, err)
@@ -306,7 +314,7 @@ func TestController_Process_HaltedShortCircuit(t *testing.T) {
306314
h.buildStore.EXPECT().Get(gomock.Any(), build.ID).Return(build, nil)
307315
h.br.EXPECT().Status(gomock.Any(), entity.BuildID{ID: build.ID}).Return(entity.BuildStatusRunning, entity.BuildMetadata{}, nil)
308316
h.batchStore.EXPECT().Get(gomock.Any(), build.BatchID).Return(entity.Batch{ID: build.BatchID, State: state}, nil)
309-
// Halted: no UpdateStatus, no speculate Publish, no buildsignal
317+
// Halted: no Update, no speculate Publish, no buildsignal
310318
// PublishAfter. The harness publishers have no expectations, so any
311319
// publish fails the test.
312320

submitqueue/orchestrator/controller/speculate/speculate.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ func (c *Controller) failOnDependency(ctx context.Context, batch entity.Batch, d
265265
// Order matters for correctness:
266266
//
267267
// 1. Cancel the in-flight Build entity (build.ID == batch.ID; one Get + one
268-
// UpdateStatus covers all builds for this batch). A future external CI
268+
// Update covers all builds for this batch). A future external CI
269269
// integration hooks in here. Idempotent: tolerate ErrNotFound (no build
270270
// was scheduled), skip if already terminal.
271271
//
@@ -332,7 +332,7 @@ func (c *Controller) cancelBatch(ctx context.Context, batch entity.Batch) error
332332
// This is the hook point for a future external CI integration: today the
333333
// system has no external runner, so the local state flip is the complete
334334
// cancellation. Once a runner exists, it must be invoked here before the
335-
// local UpdateStatus.
335+
// local Update.
336336
func (c *Controller) cancelBuild(ctx context.Context, batch entity.Batch) error {
337337
build, err := c.store.GetBuildStore().Get(ctx, batch.ID)
338338
if err != nil {
@@ -349,7 +349,9 @@ func (c *Controller) cancelBuild(ctx context.Context, batch entity.Batch) error
349349
return nil
350350
}
351351

352-
if err := c.store.GetBuildStore().UpdateStatus(ctx, batch.ID, entity.BuildStatusCancelled); err != nil {
352+
updatedBuild := build
353+
updatedBuild.Status = entity.BuildStatusCancelled
354+
if err := c.store.GetBuildStore().Update(ctx, updatedBuild); err != nil {
353355
metrics.NamedCounter(c.metricsScope, opName, "storage_errors", 1)
354356
return fmt.Errorf("failed to cancel build for batch %s: %w", batch.ID, err)
355357
}

0 commit comments

Comments
 (0)