Skip to content

Commit 3c288dd

Browse files
committed
feat(batch): make creation resumable
Persist batches in Creating, initialize assignments and reverse indexes idempotently, and resume the same batch across queue redelivery. Jira Issues: CODEM-304
1 parent b6fb666 commit 3c288dd

2 files changed

Lines changed: 711 additions & 103 deletions

File tree

submitqueue/orchestrator/controller/batch/batch.go

Lines changed: 202 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"context"
1919
"errors"
2020
"fmt"
21+
"slices"
2122

2223
"github.com/uber-go/tally"
2324
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
@@ -103,9 +104,23 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
103104
"partition_key", msg.PartitionKey,
104105
)
105106

106-
// Short-circuit if the request has been halted — either it already reached a
107-
// terminal state, or the cancel controller has recorded a cancellation intent
108-
// (RequestStateCancelling). A halted request must never spawn a new batch.
107+
// Cancellation can mark the request Cancelling after its Creating batch is persisted but before initialization finishes.
108+
// A redelivery must finish the batch's structural initialization before cancellation can safely make it terminal.
109+
if request.State == entity.RequestStateCancelling {
110+
batch, found, err := corerequest.FindBatch(ctx, c.store.GetBatchStore(), request, entity.ActiveBatchStates())
111+
if err != nil {
112+
metrics.NamedCounter(c.metricsScope, opName, "batch_lookup_errors", 1)
113+
return err
114+
}
115+
if found && batch.State == entity.BatchStateCreating {
116+
metrics.NamedCounter(c.metricsScope, opName, "completed_creating_for_cancel", 1)
117+
_, err := c.initializeBatch(ctx, batch)
118+
return err
119+
}
120+
}
121+
122+
// Short-circuit if the request has been halted.
123+
// A halted request must never spawn a new batch or publish normal forward progress.
109124
if entity.IsRequestStateHalted(request.State) {
110125
metrics.NamedCounter(c.metricsScope, opName, "skipped_halted", 1)
111126
c.logger.Infow("skipping batch for halted request",
@@ -115,6 +130,18 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
115130
return nil
116131
}
117132

133+
// A prior delivery may have claimed the request and persisted some or all of its batch before failing.
134+
// Resume that batch instead of minting a new ID so every durable step below is safe under queue redelivery.
135+
if request.State == entity.RequestStateBatched {
136+
batch, found, err := c.findAssignedBatch(ctx, request)
137+
if err != nil {
138+
return err
139+
}
140+
if found {
141+
return c.resumeBatch(ctx, request, batch)
142+
}
143+
}
144+
118145
// TODO: if capacity is full, wait here for other requests to accumulate to batch them together, or include a request into an existing batch if it's not too late.
119146

120147
// Generate a globally unique batch ID.
@@ -128,22 +155,19 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
128155
ID: fmt.Sprintf("%s/batch/%d", request.Queue, seq),
129156
Queue: request.Queue,
130157
Contains: []string{request.ID},
131-
State: entity.BatchStateCreated,
158+
State: entity.BatchStateCreating,
132159
Version: 1,
133160
}
134161

135-
// Get active batches for this queue and ask the conflict analyzer which
136-
// of them the new batch must serialize behind. The dependency set drives
137-
// the speculation graph downstream.
162+
// Get active batches for this queue and ask the conflict analyzer which of them the new batch must serialize behind.
163+
// The dependency set drives the speculation graph downstream.
138164
activeBatches, err := c.store.GetBatchStore().GetByQueueAndStates(ctx, request.Queue, entity.DependencyBatchStates())
139165
if err != nil {
140166
metrics.NamedCounter(c.metricsScope, opName, "batch_store_errors", 1)
141167
return fmt.Errorf("failed to get active batches for queue=%s: %w", request.Queue, err)
142168
}
143169

144-
// Dedupe by batch ID since a single (analyzed, in-flight) pair may be
145-
// reported with multiple Conflict entries when different conflict types
146-
// apply; the dependency graph only tracks the relation.
170+
// Dedupe by batch ID because a single pair may have multiple conflict types while the dependency graph tracks only the relation.
147171
analyzer, err := c.analyzers.For(conflict.Config{QueueName: batch.Queue})
148172
if err != nil {
149173
metrics.NamedCounter(c.metricsScope, opName, "conflict_analyzer_errors", 1)
@@ -167,36 +191,6 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
167191

168192
batch.Dependencies = conflictingIDs
169193

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-
200194
// Claim the request for this batch with a CAS-write that transitions the
201195
// request to RequestStateBatched. This CAS is the serialization point
202196
// between the batch controller and the cancel controller — without it, the
@@ -209,47 +203,30 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
209203
// T1 batch.Get(R) → R{State: Validated, Version: 1}
210204
// T2 cancel.Get(R) → R{State: Validated, Version: 1}
211205
// T3 cancel.markCancelling CAS 1→2 → R{State: Cancelling, Version: 2}
212-
// T4 cancel.findActiveBatch(R) → none (batch has not been Created yet)
206+
// T4 cancel searches active batches → none (batch has not been Created yet)
213207
// T5 cancel.cancelRequest CAS 2→3 → R{State: Cancelled, Version: 3}
214208
// T6 batch.IsRequestStateHalted(R) → false (stale in-memory copy from T1)
215209
// T7 batch.BatchStore.Create(B{[R]}) → orphan batch containing a cancelled R
216210
//
217-
// After T7 the orphan batch flows through speculate → merge → conclude;
218-
// conclude does NOT gate on the source request state when writing the terminal
219-
// state, so it would CAS the request from Cancelled back to Landed, silently
220-
// undoing the user's cancel.
211+
// After T7 the orphan batch can still flow through speculate → merge even though its only request was cancelled.
212+
// Conclude preserves a different terminal request outcome, but the batch still performs invalid work and can participate in the dependency graph without owning a live request.
221213
//
222214
// The CAS below collapses that window. Whichever of batch.UpdateState(...,
223215
// RequestStateBatched) and cancel.markCancelling(... RequestStateCancelling)
224216
// reaches storage first wins; the loser sees storage.ErrVersionMismatch:
225217
// - 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.
218+
// to its terminal state on its own; no batch or reverse-index residue was
219+
// written).
229220
// - If batch won: cancel.markCancelling will fail with ErrVersionMismatch
230221
// on its next attempt, re-fetch R, observe RequestStateBatched, and take
231222
// the batch-cancellation branch (which terminates the whole batch).
232223
//
233-
// Note on re-delivery: a retry of a batch message that already CAS'd R to
234-
// Batched but failed before/after BatchStore.Create lands in this code with
235-
// R already in RequestStateBatched. The top-level IsRequestStateHalted check
236-
// does NOT include Batched (Batched is forward-progress, not halted), so we
237-
// reach here and re-CAS Batched → Batched (a version-only bump). The bump
238-
// keeps the same serialization invariant on every attempt — if cancel sneaks
239-
// in between our Get and this CAS, our version is stale and we abandon, just
240-
// like the first-delivery case. The cost is an extra batch (the previous
241-
// attempt may have already created one) which is tolerated per the comment
242-
// on BatchStore.Create below.
224+
// On redelivery, a persisted batch is resumed with the same ID.
225+
// If the prior attempt failed after the request CAS but before BatchStore.Create, no batch exists to resume.
226+
// The next attempt performs a version-only Batched → Batched CAS before creating a fresh batch so cancellation still races against a current request version.
243227
//
244-
// Residual window: a thin race remains between this CAS and BatchStore.Create.
245-
// During that window cancel.findActiveBatch can still observe R in Batched
246-
// with no batch yet persisted, and take the request-only cancel path — which
247-
// then leaves R in Cancelled and the batch we are about to create orphaned.
248-
// Fully closing this requires cancel-side wait/retry when its pre-CAS
249-
// observation was RequestStateBatched; deferred to a follow-up since the
250-
// window is narrow (one storage round-trip) and the user-visible outcome
251-
// (request cancelled) is still correct — the orphan batch just gets
252-
// reconciled by conclude as if it had no requests to act on.
228+
// During the CAS → Create window, the cancel controller observes Batched but cannot yet resolve an assignment.
229+
// It retries rather than taking the request-only cancellation path, closing the remaining orphan-batch race.
253230
newRequestVersion := request.Version + 1
254231
if err := c.store.GetRequestStore().UpdateState(ctx, request.ID, request.Version, newRequestVersion, entity.RequestStateBatched); err != nil {
255232
// ErrVersionMismatch == cancel (or another writer) advanced R first. Ack
@@ -270,9 +247,8 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
270247
request.Version = newRequestVersion
271248
request.State = entity.RequestStateBatched
272249

273-
// Persist batch to storage.
274-
// 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.
275-
// We do not reuse batch ids, a retry of this operation will create a new batch with a new ID. The downstream logic that operates on BatchDependent should be able to handle stale entries.
250+
// Persist the batch before creating any references to it.
251+
// Creating batches are visible to ownership and cancellation lookups but excluded from dependency analysis and normal processing.
276252
if err := c.store.GetBatchStore().Create(ctx, batch); err != nil {
277253
metrics.NamedCounter(c.metricsScope, opName, "batch_store_errors", 1)
278254
return fmt.Errorf("failed to create batch in batch store: %w", err)
@@ -285,12 +261,162 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
285261
"dependency_count", len(batch.Dependencies),
286262
)
287263

288-
// Record the "batched" status in the request log. This status corresponds to
289-
// the RequestStateBatched transition CAS'd above, so it carries the request
290-
// version for reconciliation. The message ID is scoped to (requestID, status),
291-
// so a redelivery that creates a fresh batch re-emits "batched" with a
292-
// different batch_id but is deduped to the first entry — acceptable, the
293-
// request is batched either way.
264+
return c.initializeAndPublish(ctx, request, batch)
265+
}
266+
267+
// findAssignedBatch resolves the durable request-to-batch assignment.
268+
// The all-state fallback recovers batches persisted before their assignment and batches created by an older writer.
269+
func (c *Controller) findAssignedBatch(ctx context.Context, request entity.Request) (entity.Batch, bool, error) {
270+
// This is the normal resume path after initializeBatch has persisted the request-to-batch assignment.
271+
assignment, err := c.store.GetRequestBatchStore().Get(ctx, request.ID)
272+
if err == nil {
273+
batch, err := c.store.GetBatchStore().Get(ctx, assignment.BatchID)
274+
if err != nil {
275+
metrics.NamedCounter(c.metricsScope, opName, "batch_store_errors", 1)
276+
return entity.Batch{}, false, fmt.Errorf("failed to get assigned batch %s for request %s: %w", assignment.BatchID, request.ID, err)
277+
}
278+
return batch, true, nil
279+
}
280+
if !errors.Is(err, storage.ErrNotFound) {
281+
metrics.NamedCounter(c.metricsScope, opName, "request_batch_store_errors", 1)
282+
return entity.Batch{}, false, fmt.Errorf("failed to get batch assignment for request %s: %w", request.ID, err)
283+
}
284+
285+
// A missing assignment means the previous attempt may have persisted the batch and failed before recording the mapping.
286+
// Scan every state so terminal batches and batches written before assignment persistence was introduced can also be recovered.
287+
batch, found, err := corerequest.FindBatch(ctx, c.store.GetBatchStore(), request, entity.AllBatchStates())
288+
if err != nil {
289+
metrics.NamedCounter(c.metricsScope, opName, "batch_lookup_errors", 1)
290+
}
291+
if err != nil || !found {
292+
return batch, found, err
293+
}
294+
if err := c.ensureRequestAssignments(ctx, batch); err != nil {
295+
return entity.Batch{}, false, err
296+
}
297+
return batch, true, nil
298+
}
299+
300+
// resumeBatch continues the durable batch-creation handoff after redelivery.
301+
func (c *Controller) resumeBatch(ctx context.Context, request entity.Request, batch entity.Batch) error {
302+
switch batch.State {
303+
case entity.BatchStateCreating:
304+
metrics.NamedCounter(c.metricsScope, opName, "resumed_creating", 1)
305+
return c.initializeAndPublish(ctx, request, batch)
306+
case entity.BatchStateCreated:
307+
metrics.NamedCounter(c.metricsScope, opName, "resumed_created", 1)
308+
return c.publishBatch(ctx, request, batch)
309+
default:
310+
// Speculating or Merging means the handoff already succeeded.
311+
// Cancelling means the cancellation pipeline owns the batch, and terminal states leave request reconciliation to conclude.
312+
metrics.NamedCounter(c.metricsScope, opName, "resume_already_handed_off", 1)
313+
return nil
314+
}
315+
}
316+
317+
// initializeAndPublish creates the batch's reverse-index structure, marks it ready, and hands it to speculation.
318+
// Every step is idempotent so redelivery can resume the same Creating batch.
319+
func (c *Controller) initializeAndPublish(ctx context.Context, request entity.Request, batch entity.Batch) error {
320+
batch, err := c.initializeBatch(ctx, batch)
321+
if err != nil {
322+
return err
323+
}
324+
return c.publishBatch(ctx, request, batch)
325+
}
326+
327+
// initializeBatch completes the durable structure of a Creating batch without publishing normal forward progress.
328+
// Cancellation redelivery uses this path to make a partially initialized batch safe to terminate.
329+
func (c *Controller) initializeBatch(ctx context.Context, batch entity.Batch) (entity.Batch, error) {
330+
if err := c.ensureRequestAssignments(ctx, batch); err != nil {
331+
return entity.Batch{}, err
332+
}
333+
334+
bd := entity.BatchDependent{
335+
BatchID: batch.ID,
336+
Dependents: []string{},
337+
Version: 1,
338+
}
339+
if err := c.store.GetBatchDependentStore().Create(ctx, bd); err != nil && !errors.Is(err, storage.ErrAlreadyExists) {
340+
metrics.NamedCounter(c.metricsScope, opName, "batch_dependent_store_errors", 1)
341+
return entity.Batch{}, fmt.Errorf("failed to create batch dependent index for new batchID=%s: %w", batch.ID, err)
342+
}
343+
344+
for _, dependencyID := range batch.Dependencies {
345+
if err := c.ensureDependentSubscription(ctx, dependencyID, batch.ID); err != nil {
346+
return entity.Batch{}, err
347+
}
348+
}
349+
350+
newVersion := batch.Version + 1
351+
if err := c.store.GetBatchStore().UpdateState(ctx, batch.ID, batch.Version, newVersion, entity.BatchStateCreated); err != nil {
352+
metrics.NamedCounter(c.metricsScope, opName, "batch_store_errors", 1)
353+
return entity.Batch{}, fmt.Errorf("failed to mark batch %s created: %w", batch.ID, err)
354+
}
355+
batch.Version = newVersion
356+
batch.State = entity.BatchStateCreated
357+
358+
return batch, nil
359+
}
360+
361+
// ensureRequestAssignments idempotently records the owning batch for every contained request.
362+
// The assignment remains available after the batch becomes terminal so delayed redeliveries can find the original batch.
363+
func (c *Controller) ensureRequestAssignments(ctx context.Context, batch entity.Batch) error {
364+
store := c.store.GetRequestBatchStore()
365+
for _, requestID := range batch.Contains {
366+
assignment := entity.RequestBatch{
367+
RequestID: requestID,
368+
BatchID: batch.ID,
369+
Version: 1,
370+
}
371+
if err := store.Create(ctx, assignment); err != nil {
372+
if !errors.Is(err, storage.ErrAlreadyExists) {
373+
metrics.NamedCounter(c.metricsScope, opName, "request_batch_store_errors", 1)
374+
return fmt.Errorf("failed to assign request %s to batch %s: %w", requestID, batch.ID, err)
375+
}
376+
377+
existingAssignment, getErr := store.Get(ctx, requestID)
378+
if getErr != nil {
379+
metrics.NamedCounter(c.metricsScope, opName, "request_batch_store_errors", 1)
380+
return fmt.Errorf("failed to verify batch assignment for request %s: %w", requestID, getErr)
381+
}
382+
if existingAssignment.BatchID != batch.ID {
383+
// Another batch owns this request while the current batch also lists it.
384+
// Fail closed and leave the current batch Creating so it is never published or used as a dependency.
385+
metrics.NamedCounter(c.metricsScope, opName, "request_batch_conflicts", 1)
386+
return fmt.Errorf("request %s is already assigned to batch %s instead of %s", requestID, existingAssignment.BatchID, batch.ID)
387+
}
388+
}
389+
}
390+
return nil
391+
}
392+
393+
// ensureDependentSubscription idempotently adds dependentID to the reverse index for dependencyID.
394+
func (c *Controller) ensureDependentSubscription(ctx context.Context, dependencyID, dependentID string) error {
395+
existing, err := c.store.GetBatchDependentStore().Get(ctx, dependencyID)
396+
if err != nil {
397+
metrics.NamedCounter(c.metricsScope, opName, "batch_dependent_store_errors", 1)
398+
return fmt.Errorf("failed to get batch dependent for batchID=%s: %w", dependencyID, err)
399+
}
400+
401+
// The dependent subscription has already been created (e.g. on a prior attempt) - no-op.
402+
if slices.Contains(existing.Dependents, dependentID) {
403+
return nil
404+
}
405+
406+
dependents := append(append([]string{}, existing.Dependents...), dependentID)
407+
newVersion := existing.Version + 1
408+
if err := c.store.GetBatchDependentStore().UpdateDependents(ctx, dependencyID, existing.Version, newVersion, dependents); err != nil {
409+
metrics.NamedCounter(c.metricsScope, opName, "batch_dependent_store_errors", 1)
410+
return fmt.Errorf("failed to update batch dependent index for existing batchID=%s and new batchID=%s: %w", dependencyID, dependentID, err)
411+
}
412+
return nil
413+
}
414+
415+
// publishBatch records the batched request status and performs the reliable handoff to speculation.
416+
// Both publishes use deterministic IDs, so repeating them after redelivery is safe.
417+
func (c *Controller) publishBatch(ctx context.Context, request entity.Request, batch entity.Batch) error {
418+
// Record the "batched" status corresponding to the RequestStateBatched transition.
419+
// The message ID is scoped to (requestID, status), so redelivery is deduplicated.
294420
logEntry := entity.NewRequestLog(request.ID, entity.RequestStatusBatched, request.Version, "", map[string]string{
295421
"batch_id": batch.ID,
296422
})
@@ -300,8 +426,6 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
300426
}
301427

302428
// Publish to speculate topic for further processing.
303-
// If it fails and the controller retries, a new batch will be created with the new batch ID but the same request ID.
304-
// The downstream logic should be able to handle stale entries by looking at the state of the batch.
305429
if err := c.publish(ctx, topickey.TopicKeySpeculate, batch.ID, batch.Queue); err != nil {
306430
metrics.NamedCounter(c.metricsScope, opName, "publish_errors", 1)
307431
return fmt.Errorf("failed to publish batch ID to speculate topic: %w", err)

0 commit comments

Comments
 (0)