@@ -104,10 +104,10 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (r
104104 "partition_key" , msg .PartitionKey ,
105105 )
106106
107- // Short-circuit when the batch is in BatchStateCancelling — the cancel
108- // controller has handed the batch off to speculate, which owns the terminal
109- // write to Cancelled and the downstream dependent / conclude publishes. We
110- // must not race it to conclude (conclude requires terminal). Silently ack .
107+ var batchScore float64
108+ // Short-circuit when the batch is in BatchStateCancelling. The cancel
109+ // controller has handed the batch off to speculate, which owns the
110+ // terminal write and downstream fanout .
111111 if batch .State == entity .BatchStateCancelling {
112112 c .metricsScope .Counter ("skipped_cancelling" ).Inc (1 )
113113 c .logger .Infow ("skipping score for cancelling batch" ,
@@ -116,12 +116,8 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (r
116116 return nil
117117 }
118118
119- // Short-circuit if the batch is already terminal. Score never writes a
120- // terminal state, so it owns no recovery here: whichever controller wrote
121- // the terminal state (speculate.cancelBatch / failOnDependency, or merge)
122- // already published to conclude, and speculate's terminal self-heal
123- // republishes conclude on every redelivery of a terminal batch. Silently
124- // ack — same pattern as build / buildsignal on halted.
119+ // Score owns no terminal-state recovery. The controller that wrote the
120+ // terminal state owns its remaining fanout.
125121 if batch .State .IsTerminal () {
126122 c .metricsScope .Counter ("skipped_terminal" ).Inc (1 )
127123 c .logger .Infow ("skipping score for terminal batch" ,
@@ -131,25 +127,54 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (r
131127 return nil
132128 }
133129
134- // Score the batch. The scorer resolves the batch's changes itself.
135- batchScore , err := c .scoreBatch (ctx , batch )
136- if err != nil {
137- metrics .NamedCounter (c .metricsScope , opName , "scorer_errors" , 1 )
138- return fmt .Errorf ("failed to score batch %s: %w" , batch .ID , err )
139- }
130+ switch batch .State {
131+ case entity .BatchStateCreated :
132+ // Score the batch. The scorer resolves the batch's changes itself.
133+ batchScore , err = c .scoreBatch (ctx , batch )
134+ if err != nil {
135+ metrics .NamedCounter (c .metricsScope , opName , "scorer_errors" , 1 )
136+ return fmt .Errorf ("failed to score batch %s: %w" , batch .ID , err )
137+ }
138+
139+ newVersion := batch .Version + 1
140+ err = c .store .GetBatchStore ().UpdateScoreAndState (
141+ ctx ,
142+ batch .ID ,
143+ batch .Version ,
144+ newVersion ,
145+ batchScore ,
146+ entity .BatchStateScored ,
147+ )
148+ if err != nil {
149+ metrics .NamedCounter (c .metricsScope , opName , "storage_errors" , 1 )
150+ return fmt .Errorf ("failed to update score for batch %s: %w" , batch .ID , err )
151+ }
152+
153+ batch .Version = newVersion
154+ batch .Score = batchScore
155+ batch .State = entity .BatchStateScored
156+ c .logger .Infow ("scored batch" ,
157+ "batch_id" , batch .ID ,
158+ "score" , batchScore ,
159+ )
140160
141- // Atomically update score and state to "scored" in the database
142- newVersion := batch .Version + 1
143- if err := c .store .GetBatchStore ().UpdateScoreAndState (ctx , batch .ID , batch .Version , newVersion , batchScore , entity .BatchStateScored ); err != nil {
144- metrics .NamedCounter (c .metricsScope , opName , "storage_errors" , 1 )
145- return fmt .Errorf ("failed to update score for batch %s: %w" , batch .ID , err )
146- }
147- batch .Version = newVersion
161+ case entity .BatchStateScored :
162+ // The durable transition already happened, but its fanout may be
163+ // incomplete. Preserve the committed score and replay all outputs.
164+ batchScore = batch .Score
165+
166+ case entity .BatchStateSpeculating , entity .BatchStateMerging :
167+ // Normal under at-least-once delivery: a prior score attempt may have
168+ // published to speculate before its acknowledgement was recorded.
169+ // Downstream processing has already advanced the batch, so this stale
170+ // delivery is satisfied and must not regress the batch to Scored.
171+ c .metricsScope .Counter ("skipped_downstream" ).Inc (1 )
172+ return nil
148173
149- c . logger . Infow ( "scored batch" ,
150- "batch_id" , batch . ID ,
151- "score " , batchScore ,
152- )
174+ default :
175+ c . metricsScope . Counter ( "unexpected_state" ). Inc ( 1 )
176+ return fmt . Errorf ( "unexpected batch state %q for batch %s " , batch . State , batch . ID )
177+ }
153178
154179 // Publish request log entries for all requests in the batch
155180 if err := corerequest .PublishBatchLogs (ctx , c .registry , batch .Contains , entity .RequestStatusScored , map [string ]string {
0 commit comments