Skip to content

Commit c2c3a8a

Browse files
committed
feat(batch): gate publication on batch readiness
1 parent 549017b commit c2c3a8a

2 files changed

Lines changed: 316 additions & 54 deletions

File tree

submitqueue/orchestrator/controller/batch/batch.go

Lines changed: 58 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
331349
func (c *Controller) publish(ctx context.Context, key consumer.TopicKey, batchID string, partitionKey string) error {
332350
bid := entity.BatchID{ID: batchID}

0 commit comments

Comments
 (0)