@@ -42,9 +42,18 @@ const (
4242 testURI = "git://repo/monorepo/main/abc123"
4343)
4444
45+ // rescheduledMsg matches a gate-wait re-publish: same partition, but a fresh message id —
46+ // re-publishing under the in-flight delivery's id would be silently deduped against its
47+ // still-present message-store row and lost on ack.
48+ func rescheduledMsg (msg entityqueue.Message ) bool {
49+ // Fresh non-empty id, same queue.
50+ return msg .ID != testID && msg .ID != "" && msg .PartitionKey == testQueue
51+ }
52+
4553type processMocks struct {
4654 reqStore * storagemock.MockRequestStore
4755 queueStore * storagemock.MockQueueStore
56+ publisher * mqmock.MockPublisher
4857}
4958
5059func newController (t * testing.T , ctrl * gomock.Controller ) (* Controller , processMocks ) {
@@ -53,13 +62,22 @@ func newController(t *testing.T, ctrl *gomock.Controller) (*Controller, processM
5362 m := processMocks {
5463 reqStore : storagemock .NewMockRequestStore (ctrl ),
5564 queueStore : storagemock .NewMockQueueStore (ctrl ),
65+ publisher : mqmock .NewMockPublisher (ctrl ),
5666 }
5767
5868 store := storagemock .NewMockStorage (ctrl )
5969 store .EXPECT ().GetRequestStore ().Return (m .reqStore ).AnyTimes ()
6070 store .EXPECT ().GetQueueStore ().Return (m .queueStore ).AnyTimes ()
6171
62- c := NewController (zap .NewNop ().Sugar (), tally .NewTestScope ("test" , nil ), store , queueconfigdefault .NewStore (), stovepipemq .TopicKeyProcess , "stovepipe-process" )
72+ queue := mqmock .NewMockQueue (ctrl )
73+ queue .EXPECT ().Publisher ().Return (m .publisher ).AnyTimes ()
74+
75+ registry , err := consumer .NewTopicRegistry ([]consumer.TopicConfig {
76+ {Key : stovepipemq .TopicKeyProcess , Name : "process" , Queue : queue },
77+ })
78+ require .NoError (t , err )
79+
80+ c := NewController (zap .NewNop ().Sugar (), tally .NewTestScope ("test" , nil ), store , queueconfigdefault .NewStore (), registry , stovepipemq .TopicKeyProcess , "stovepipe-process" )
6381 return c , m
6482}
6583
@@ -159,7 +177,7 @@ func TestProcess(t *testing.T) {
159177 },
160178 },
161179 {
162- name : "latest accepted head awaits slot when gate closed" ,
180+ name : "latest accepted head reschedules when gate closed" ,
163181 setup : func (m processMocks ) {
164182 m .reqStore .EXPECT ().Get (gomock .Any (), testID ).Return (acceptedRequest (testID ), nil )
165183 m .queueStore .EXPECT ().Get (gomock .Any (), testQueue ).Return (entity.Queue {
@@ -168,6 +186,52 @@ func TestProcess(t *testing.T) {
168186 InFlightCount : 1 ,
169187 Version : 1 ,
170188 }, nil )
189+ m .publisher .EXPECT ().
190+ PublishAfter (gomock .Any (), "process" , gomock .Cond (rescheduledMsg ), int64 (5000 )).
191+ Return (nil )
192+ },
193+ },
194+ {
195+ name : "gate reschedule publish error surfaces" ,
196+ wantErr : true ,
197+ wantRetry : false ,
198+ setup : func (m processMocks ) {
199+ m .reqStore .EXPECT ().Get (gomock .Any (), testID ).Return (acceptedRequest (testID ), nil )
200+ m .queueStore .EXPECT ().Get (gomock .Any (), testQueue ).Return (entity.Queue {
201+ Name : testQueue ,
202+ LatestRequestID : testID ,
203+ InFlightCount : 1 ,
204+ Version : 1 ,
205+ }, nil )
206+ m .publisher .EXPECT ().
207+ PublishAfter (gomock .Any (), "process" , gomock .Cond (rescheduledMsg ), int64 (5000 )).
208+ Return (errors .New ("queue down" ))
209+ },
210+ },
211+ {
212+ name : "gate closed after slot claim race reschedules" ,
213+ setup : func (m processMocks ) {
214+ m .reqStore .EXPECT ().Get (gomock .Any (), testID ).Return (acceptedRequest (testID ), nil )
215+ m .queueStore .EXPECT ().Get (gomock .Any (), testQueue ).Return (entity.Queue {
216+ Name : testQueue ,
217+ LatestRequestID : testID ,
218+ Version : 1 ,
219+ }, nil )
220+ m .queueStore .EXPECT ().Update (gomock .Any (), entity.Queue {
221+ Name : testQueue ,
222+ LatestRequestID : testID ,
223+ InFlightCount : 1 ,
224+ Version : 1 ,
225+ }, int32 (1 ), int32 (2 )).Return (storage .ErrVersionMismatch )
226+ m .queueStore .EXPECT ().Get (gomock .Any (), testQueue ).Return (entity.Queue {
227+ Name : testQueue ,
228+ LatestRequestID : testID ,
229+ InFlightCount : 1 ,
230+ Version : 2 ,
231+ }, nil )
232+ m .publisher .EXPECT ().
233+ PublishAfter (gomock .Any (), "process" , gomock .Cond (rescheduledMsg ), int64 (5000 )).
234+ Return (nil )
171235 },
172236 },
173237 {
@@ -195,22 +259,6 @@ func TestProcess(t *testing.T) {
195259 m .reqStore .EXPECT ().Update (gomock .Any (), updatedReq , int32 (1 ), int32 (2 )).Return (nil )
196260 },
197261 },
198- {
199- name : "gate closed after reload acks without failing" ,
200- setup : func (m processMocks ) {
201- m .reqStore .EXPECT ().Get (gomock .Any (), testID ).Return (acceptedRequest (testID ), nil )
202- m .queueStore .EXPECT ().Get (gomock .Any (), testQueue ).Return (entity.Queue {
203- Name : testQueue , LatestRequestID : testID , Version : 1 ,
204- }, nil )
205- m .queueStore .EXPECT ().Update (gomock .Any (), entity.Queue {
206- Name : testQueue , LatestRequestID : testID , InFlightCount : 1 , Version : 1 ,
207- }, int32 (1 ), int32 (2 )).Return (storage .ErrVersionMismatch )
208- // Reload: another admit took the last slot — gate now closed, defer (ack).
209- m .queueStore .EXPECT ().Get (gomock .Any (), testQueue ).Return (entity.Queue {
210- Name : testQueue , LatestRequestID : testID , InFlightCount : 1 , Version : 2 ,
211- }, nil )
212- },
213- },
214262 {
215263 name : "reload after claim mismatch supersedes a now-stale head" ,
216264 setup : func (m processMocks ) {
@@ -390,3 +438,13 @@ func TestProcess(t *testing.T) {
390438 })
391439 }
392440}
441+
442+ func TestRescheduleProcessRequiresPositiveDelay (t * testing.T ) {
443+ ctrl := gomock .NewController (t )
444+ c , _ := newController (t , ctrl )
445+
446+ err := c .rescheduleProcess (context .Background (), acceptedRequest (testID ), 1 , 0 )
447+
448+ require .Error (t , err )
449+ assert .False (t , errs .IsRetryable (err ))
450+ }
0 commit comments