@@ -106,6 +106,7 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
106106 // Short-circuit if the request has been halted — either it already reached a
107107 // terminal state, or the cancel controller has recorded a cancellation intent
108108 // (RequestStateCancelling). A halted request must never spawn a new batch.
109+ // If cancellation races with an attempt already initializing below, speculate re-checks the contained request state before starting work.
109110 if entity .IsRequestStateHalted (request .State ) {
110111 metrics .NamedCounter (c .metricsScope , opName , "skipped_halted" , 1 )
111112 c .logger .Infow ("skipping batch for halted request" ,
@@ -128,7 +129,7 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
128129 ID : fmt .Sprintf ("%s/batch/%d" , request .Queue , seq ),
129130 Queue : request .Queue ,
130131 Contains : []string {request .ID },
131- State : entity .BatchStateCreated ,
132+ State : entity .BatchStateCreating ,
132133 Version : 1 ,
133134 }
134135
@@ -167,36 +168,6 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
167168
168169 batch .Dependencies = conflictingIDs
169170
170- // Update reverse index for each conflicting batch (BatchDependent =
171- // "batches that depend on me"). One UpdateDependents call per conflict.
172- for _ , depID := range conflictingIDs {
173- existing , err := c .store .GetBatchDependentStore ().Get (ctx , depID )
174- if err != nil {
175- metrics .NamedCounter (c .metricsScope , opName , "batch_dependent_store_errors" , 1 )
176- return fmt .Errorf ("failed to get batch dependent for batchID=%s: %w" , depID , err )
177- }
178-
179- dependents := append (existing .Dependents , batch .ID )
180-
181- newVersion := existing .Version + 1
182- if err := c .store .GetBatchDependentStore ().UpdateDependents (ctx , depID , existing .Version , newVersion , dependents ); err != nil {
183- metrics .NamedCounter (c .metricsScope , opName , "batch_dependent_store_errors" , 1 )
184- return fmt .Errorf ("failed to update batch dependent index for existing batchID=%s and new batchID=%s: %w" , depID , batch .ID , err )
185- }
186- }
187-
188- // Create new reverse index entry for the new batch. It would be empty for now, but will be updated as new batches are created that conflict with this batch.
189- bd := entity.BatchDependent {
190- BatchID : batch .ID ,
191- Dependents : []string {},
192- Version : 1 ,
193- }
194-
195- if err := c .store .GetBatchDependentStore ().Create (ctx , bd ); err != nil {
196- metrics .NamedCounter (c .metricsScope , opName , "batch_dependent_store_errors" , 1 )
197- return fmt .Errorf ("failed to create batch dependent index for new batchID=%s: %w" , batch .ID , err )
198- }
199-
200171 // Claim the request for this batch with a CAS-write that transitions the
201172 // request to RequestStateBatched. This CAS is the serialization point
202173 // between the batch controller and the cancel controller — without it, the
@@ -223,9 +194,8 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
223194 // RequestStateBatched) and cancel.markCancelling(... RequestStateCancelling)
224195 // reaches storage first wins; the loser sees storage.ErrVersionMismatch:
225196 // - If cancel won: this CAS fails. We ack the message (cancel will drive R
226- // to its terminal state on its own; no batch is needed). The reverse-index
227- // entry above becomes a dangling BatchDependent — tolerated per the
228- // "downstream should handle stale entries" contract on this store.
197+ // to its terminal state on its own; no batch or reverse-index data has
198+ // been written).
229199 // - If batch won: cancel.markCancelling will fail with ErrVersionMismatch
230200 // on its next attempt, re-fetch R, observe RequestStateBatched, and take
231201 // the batch-cancellation branch (which terminates the whole batch).
@@ -270,6 +240,12 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
270240 request .Version = newRequestVersion
271241 request .State = entity .RequestStateBatched
272242
243+ // Persist the batch before creating references to it. A Creating batch is not eligible for dependency analysis or normal processing.
244+ if err := c .store .GetBatchStore ().Create (ctx , batch ); err != nil {
245+ metrics .NamedCounter (c .metricsScope , opName , "batch_store_errors" , 1 )
246+ return fmt .Errorf ("failed to create batch in batch store: %w" , err )
247+ }
248+
273249 for _ , requestID := range batch .Contains {
274250 association := entity.RequestBatch {
275251 RequestID : requestID ,
@@ -278,16 +254,17 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
278254 }
279255 if err := c .store .GetRequestBatchStore ().Create (ctx , association ); err != nil {
280256 metrics .NamedCounter (c .metricsScope , opName , "request_batch_store_errors" , 1 )
257+ metrics .NamedCounter (c .metricsScope , opName , "batch_abandoned_creating" , 1 )
281258 return fmt .Errorf ("failed to associate request %s with batch %s: %w" , requestID , batch .ID , err )
282259 }
283260 }
284261
285- // Persist batch to storage.
286- // 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.
287- // We do not reuse batch ids, a retry of this operation will create a new batch with a new ID. The downstream logic should tolerate stale BatchDependent and RequestBatch entries.
288- if err := c . store . GetBatchStore (). Create ( ctx , batch ); err != nil {
289- metrics . NamedCounter ( c . metricsScope , opName , "batch_store_errors" , 1 )
290- return fmt . Errorf ( "failed to create batch in batch store: %w" , err )
262+ batch , err = c . populateBatch ( ctx , batch )
263+ if err != nil {
264+ metrics . NamedCounter ( c . metricsScope , opName , "batch_abandoned_creating" , 1 )
265+ // Retries intentionally mint a new batch ID. Failures may therefore leave unpublished Creating or Created attempts behind.
266+ // These attempts are inert and can be removed by a future background cleanup job if their volume becomes significant.
267+ return err
291268 }
292269
293270 c .logger .Infow ("batch created" ,
@@ -308,6 +285,7 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
308285 })
309286 if err := corerequest .PublishLog (ctx , c .registry , logEntry , request .ID ); err != nil {
310287 metrics .NamedCounter (c .metricsScope , opName , "request_log_errors" , 1 )
288+ metrics .NamedCounter (c .metricsScope , opName , "batch_abandoned_created" , 1 )
311289 return fmt .Errorf ("failed to publish request log for request %s: %w" , request .ID , err )
312290 }
313291
@@ -316,6 +294,7 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
316294 // The downstream logic should be able to handle stale entries by looking at the state of the batch.
317295 if err := c .publish (ctx , topickey .TopicKeySpeculate , batch .ID , batch .Queue ); err != nil {
318296 metrics .NamedCounter (c .metricsScope , opName , "publish_errors" , 1 )
297+ metrics .NamedCounter (c .metricsScope , opName , "batch_abandoned_created" , 1 )
319298 return fmt .Errorf ("failed to publish batch ID to speculate topic: %w" , err )
320299 }
321300
@@ -327,6 +306,45 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
327306 return nil // Success - message will be acked
328307}
329308
309+ // populateBatch creates the reverse-index structure and marks a Creating batch ready for publication.
310+ func (c * Controller ) populateBatch (ctx context.Context , batch entity.Batch ) (entity.Batch , error ) {
311+ batchDependent := entity.BatchDependent {
312+ BatchID : batch .ID ,
313+ Dependents : []string {},
314+ Version : 1 ,
315+ }
316+ if err := c .store .GetBatchDependentStore ().Create (ctx , batchDependent ); err != nil {
317+ metrics .NamedCounter (c .metricsScope , opName , "batch_dependent_store_errors" , 1 )
318+ return entity.Batch {}, fmt .Errorf ("failed to create batch dependent index for new batchID=%s: %w" , batch .ID , err )
319+ }
320+
321+ for _ , dependencyID := range batch .Dependencies {
322+ existing , err := c .store .GetBatchDependentStore ().Get (ctx , dependencyID )
323+ if err != nil {
324+ metrics .NamedCounter (c .metricsScope , opName , "batch_dependent_store_errors" , 1 )
325+ return entity.Batch {}, fmt .Errorf ("failed to get batch dependent for batchID=%s: %w" , dependencyID , err )
326+ }
327+
328+ dependents := append (existing .Dependents , batch .ID )
329+ newVersion := existing .Version + 1
330+ if err := c .store .GetBatchDependentStore ().UpdateDependents (ctx , dependencyID , existing .Version , newVersion , dependents ); err != nil {
331+ metrics .NamedCounter (c .metricsScope , opName , "batch_dependent_store_errors" , 1 )
332+ return entity.Batch {}, fmt .Errorf ("failed to update batch dependent index for existing batchID=%s and new batchID=%s: %w" , dependencyID , batch .ID , err )
333+ }
334+ }
335+
336+ // The batch's own reverse-index row now exists and every dependency lists this batch as a dependent.
337+ // Structural initialization is complete, so transition Creating → Created to make the batch ready for processing once published to speculate.
338+ newVersion := batch .Version + 1
339+ if err := c .store .GetBatchStore ().UpdateState (ctx , batch .ID , batch .Version , newVersion , entity .BatchStateCreated ); err != nil {
340+ metrics .NamedCounter (c .metricsScope , opName , "batch_store_errors" , 1 )
341+ return entity.Batch {}, fmt .Errorf ("failed to mark batch %s created: %w" , batch .ID , err )
342+ }
343+ batch .Version = newVersion
344+ batch .State = entity .BatchStateCreated
345+ return batch , nil
346+ }
347+
330348// publish publishes a batch ID to the specified topic key.
331349func (c * Controller ) publish (ctx context.Context , key consumer.TopicKey , batchID string , partitionKey string ) error {
332350 bid := entity.BatchID {ID : batchID }
0 commit comments