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
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
5753import (
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