Skip to content

Commit d95ab4e

Browse files
committed
fix(cancel): resolve durable batch ownership
Use request-to-batch assignments with a legacy all-state fallback and retry while a claimed request's batch is not yet visible. Jira Issues: CODEM-304
1 parent d5ef2a5 commit d95ab4e

4 files changed

Lines changed: 375 additions & 42 deletions

File tree

submitqueue/entity/batch.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ func (s BatchState) IsTerminal() bool {
6262
}
6363
}
6464

65+
var nonCancellableBatchStates = map[BatchState]bool{
66+
BatchStateUnknown: true,
67+
BatchStateCreating: true,
68+
BatchStateMerging: true,
69+
BatchStateSucceeded: true,
70+
BatchStateFailed: true,
71+
BatchStateCancelled: true,
72+
}
73+
74+
// IsCancellable returns true if cancellation should transition or republish a batch in this state.
75+
// New non-terminal states are cancellable by default unless explicitly excluded above.
76+
func (s BatchState) IsCancellable() bool {
77+
return !nonCancellableBatchStates[s]
78+
}
79+
6580
// IsBatchStateHalted returns true if the batch is either terminal or in the process of being cancelled.
6681
// Forward-progress controllers (build, buildsignal, speculate, merge) use this to short-circuit
6782
// work for batches that the user has asked to cancel — even though Cancelling is non-terminal, no

submitqueue/entity/batch_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@ func TestBatchState_IsTerminal(t *testing.T) {
4545
}
4646
}
4747

48+
func TestIsCancellable(t *testing.T) {
49+
assert.True(t, BatchStateCreated.IsCancellable())
50+
assert.True(t, BatchStateSpeculating.IsCancellable())
51+
assert.True(t, BatchStateCancelling.IsCancellable())
52+
assert.True(t, BatchState("future").IsCancellable())
53+
assert.False(t, BatchStateUnknown.IsCancellable())
54+
assert.False(t, BatchStateCreating.IsCancellable())
55+
assert.False(t, BatchStateMerging.IsCancellable())
56+
assert.False(t, BatchStateSucceeded.IsCancellable())
57+
assert.False(t, BatchStateFailed.IsCancellable())
58+
assert.False(t, BatchStateCancelled.IsCancellable())
59+
}
60+
4861
func TestActiveBatchStates_ExcludesCreating(t *testing.T) {
4962
assert.NotContains(t, ActiveBatchStates(), BatchStateCreating)
5063
}

submitqueue/orchestrator/controller/cancel/cancel.go

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,11 @@
2323
// RequestStatusCancelled log entry. This path is fully owned by the cancel
2424
// controller.
2525
//
26-
// - The request is already part of an active batch — the controller performs
27-
// a single intent CAS on the batch (advancing it to BatchStateCancelling)
28-
// and hands off to the speculate controller by publishing the batch ID to
29-
// TopicKeySpeculate. The speculate controller then owns: cancelling any
30-
// in-flight Build entity for the batch, fanning out to dependents, the
31-
// terminal CAS to BatchStateCancelled, and publishing to conclude. Cancel
32-
// does no terminal write and no downstream fan-out on the batch path.
26+
// - The request is associated with one or more batch attempts — the controller
27+
// records cancellation intent on every cancellable attempt and hands each one
28+
// to speculate. Creating attempts are ignored because their reverse indexes
29+
// may be incomplete, while Merging and terminal attempts retain their existing
30+
// outcome for conclude to reconcile.
3331
//
3432
// The split exists so that the terminal write and the work that must precede
3533
// it (cancelling builds, respeculating dependents) live in the same controller
@@ -40,11 +38,9 @@
4038
// its terminal state.
4139
//
4240
// The controller is idempotent: re-delivery of the same CancelRequest after
43-
// the terminal request transition is a no-op; re-delivery after the
44-
// Cancelling write skips the mark-cancelling step and proceeds straight to
45-
// the batch lookup. On the batch path, re-delivery against an already
46-
// Cancelling batch re-publishes to TopicKeySpeculate (a cheap no-op nudge
47-
// the speculate controller absorbs).
41+
// the terminal request transition is a no-op. Re-delivery after a Cancelling
42+
// write skips that CAS, finds every batch ID containing the request again, and
43+
// republishes matching attempts already in BatchStateCancelling.
4844
//
4945
// Concurrent producers surface as the intrinsically retryable
5046
// storage.ErrVersionMismatch; the controller returns the wrapped error as-is
@@ -56,7 +52,9 @@ package cancel
5652

5753
import (
5854
"context"
55+
"errors"
5956
"fmt"
57+
"sort"
6058

6159
"github.com/uber-go/tally"
6260
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
@@ -144,16 +142,46 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
144142
return err
145143
}
146144

147-
// Look for an active batch that already contains this request.
148-
batch, found, err := c.findActiveBatch(ctx, request)
145+
// Find every batch associated with this request. Retries may create multiple batch IDs, and each persisted attempt must be handled.
146+
batches, err := c.findBatches(ctx, request)
149147
if err != nil {
150148
return err
151149
}
152150

153-
if !found {
151+
// Return the first failure so the existing linear error-classification chain remains intact.
152+
// Matches are sorted by ID below, making the selected error deterministic while every failure is still logged and counted.
153+
var firstErr error
154+
foundApplicableBatch := false
155+
for _, batch := range batches {
156+
switch {
157+
case batch.State.IsCancellable():
158+
foundApplicableBatch = true
159+
if err := c.cancelBatch(ctx, batch); err != nil {
160+
metrics.NamedCounter(c.metricsScope, opName, "batch_cancel_errors", 1)
161+
c.logger.Errorw("failed to cancel batch",
162+
"batch_id", batch.ID,
163+
"batch_state", string(batch.State),
164+
"error", err,
165+
)
166+
if firstErr == nil {
167+
firstErr = err
168+
}
169+
}
170+
case batch.State == entity.BatchStateMerging:
171+
// Merge owns the outcome once it has started. Conclude will reconcile the request with that outcome.
172+
foundApplicableBatch = true
173+
metrics.NamedCounter(c.metricsScope, opName, "batch_merging", 1)
174+
case batch.State.IsTerminal():
175+
// The terminal batch outcome wins; conclude may not have reconciled the request yet.
176+
foundApplicableBatch = true
177+
metrics.NamedCounter(c.metricsScope, opName, "batch_already_terminal", 1)
178+
}
179+
}
180+
181+
if !foundApplicableBatch {
154182
return c.cancelRequest(ctx, request, cancelReq.Reason)
155183
}
156-
return c.cancelBatch(ctx, batch)
184+
return firstErr
157185
}
158186

159187
// markCancelling transitions the request to RequestStateCancelling (intent) if it
@@ -182,31 +210,36 @@ func (c *Controller) markCancelling(ctx context.Context, request entity.Request)
182210
return request, nil
183211
}
184212

185-
// findActiveBatch scans all active batches in the request's queue for one whose
186-
// Contains list includes the request. Returns (batch, true, nil) on a hit,
187-
// (zero, false, nil) when the request is not yet batched, and any storage
188-
// error otherwise.
189-
//
190-
// BatchStateCancelling is included in the active-state list so an idempotent
191-
// redelivery of the cancel message (the prior pass wrote the intent but the
192-
// speculate hand-off publish failed) still resolves the batch and re-attempts
193-
// the publish.
194-
func (c *Controller) findActiveBatch(ctx context.Context, request entity.Request) (entity.Batch, bool, error) {
195-
// TODO: Scans all the batches in flight - make it more efficient?
196-
active, err := c.store.GetBatchStore().GetByQueueAndStates(ctx, request.Queue, entity.ActiveBatchStates())
213+
// findBatches resolves every batch attempt associated with the request.
214+
// Associations whose batch was never persisted are stale retry artifacts and are ignored.
215+
func (c *Controller) findBatches(ctx context.Context, request entity.Request) ([]entity.Batch, error) {
216+
associations, err := c.store.GetRequestBatchStore().GetByRequestID(ctx, request.ID)
197217
if err != nil {
198-
metrics.NamedCounter(c.metricsScope, opName, "batch_store_errors", 1)
199-
return entity.Batch{}, false, fmt.Errorf("failed to get active batches for queue=%s: %w", request.Queue, err)
218+
metrics.NamedCounter(c.metricsScope, opName, "request_batch_store_errors", 1)
219+
return nil, fmt.Errorf("failed to get batch associations for request %s: %w", request.ID, err)
200220
}
201221

202-
for _, b := range active {
203-
for _, rid := range b.Contains {
204-
if rid == request.ID {
205-
return b, true, nil
222+
var batches []entity.Batch
223+
for _, association := range associations {
224+
batch, err := c.store.GetBatchStore().Get(ctx, association.BatchID)
225+
if err != nil {
226+
if errors.Is(err, storage.ErrNotFound) {
227+
// The association may precede batch persistence or may outlive a failed attempt.
228+
// If the batch is later persisted and published, speculate re-checks the contained request state before starting work.
229+
metrics.NamedCounter(c.metricsScope, opName, "stale_batch_associations", 1)
230+
continue
206231
}
232+
metrics.NamedCounter(c.metricsScope, opName, "batch_store_errors", 1)
233+
return nil, fmt.Errorf("failed to get associated batch %s for request %s: %w", association.BatchID, request.ID, err)
207234
}
235+
batches = append(batches, batch)
208236
}
209-
return entity.Batch{}, false, nil
237+
238+
// The batches are independent, but deterministic order stabilizes logs, tests, and first-error selection.
239+
sort.Slice(batches, func(i, j int) bool {
240+
return batches[i].ID < batches[j].ID
241+
})
242+
return batches, nil
210243
}
211244

212245
// cancelRequest drives the terminal transition (Cancelling → Cancelled) for a

0 commit comments

Comments
 (0)