@@ -125,8 +125,7 @@ type (
125125 js jetstream.JetStream
126126 flow uint16
127127 ackInboxPrefix string
128- // replyPrefix caches "<inbox>.<flow>.<gap>." to avoid fmt.Sprintf per message.
129- // Rebuilt when flow changes via server ack.
128+ // replyPrefix caches "<inbox>.<flow>.<gap>." fixed at initialization; not rebuilt when flow changes.
130129 replyPrefix string
131130 ackSub * nats.Subscription
132131 sequence uint64
@@ -191,7 +190,13 @@ func NewFastPublisher(js jetstream.JetStream, opts ...FastPublisherOpt) (FastPub
191190 opts : pubOpts ,
192191 errHandler : pubOpts .errHandler ,
193192 }
194- fp .buildReplyPrefix ()
193+
194+ gap := "fail"
195+ if fp .opts .continueOnGap {
196+ gap = "ok"
197+ }
198+ fp .replyPrefix = fp .ackInboxPrefix + "." + strconv .FormatUint (uint64 (fp .flow ), 10 ) + "." + gap + "."
199+
195200 return fp , nil
196201}
197202
@@ -210,18 +215,24 @@ func (fp *fastPublisher) sendPing() error {
210215
211216// waitForStall blocks until the stall channel is signaled, sending periodic
212217// pings to recover lost acks. Returns an error only on total timeout.
218+ // The deadline is intentionally shared across all re-stall cycles: ackTimeout is a
219+ // hard ceiling on total wait time, not per-ack wait time.
213220// Must be called WITHOUT holding fp.mu.
214221func (fp * fastPublisher ) waitForStall (stallCh <- chan struct {}) error {
215- // Split timeout into intervals: try up to 2 pings before giving up.
216- pingInterval := fp .opts .ackTimeout / 3
217- deadline := time .NewTimer (fp .opts .ackTimeout )
222+ deadline , ping , pingInterval := fp .newPingTimers ()
218223 defer deadline .Stop ()
219- ping := time .NewTimer (pingInterval )
220224 defer ping .Stop ()
221225
222226 for {
223227 select {
224228 case <- stallCh :
229+ // After receiving a new ack, it could be we still need to wait more if we're being slowed down.
230+ fp .mu .Lock ()
231+ waitForAck := fp .waitForAck ()
232+ fp .mu .Unlock ()
233+ if waitForAck {
234+ continue
235+ }
225236 return nil
226237 case <- ping .C :
227238 if err := fp .sendPing (); err != nil {
@@ -234,14 +245,12 @@ func (fp *fastPublisher) waitForStall(stallCh <-chan struct{}) error {
234245 }
235246}
236247
237- // buildReplyPrefix pre-computes the stable portion of the reply subject:
238- // "<inbox>.<flow>.<gap>." so that per-message we only append "<seq>.<op>.$FI".
239- func (fp * fastPublisher ) buildReplyPrefix () {
240- gap := "fail"
241- if fp .opts .continueOnGap {
242- gap = "ok"
243- }
244- fp .replyPrefix = fp .ackInboxPrefix + "." + strconv .FormatUint (uint64 (fp .flow ), 10 ) + "." + gap + "."
248+ func (fp * fastPublisher ) newPingTimers () (deadline , ping * time.Timer , pingInterval time.Duration ) {
249+ // wait for commit ack with periodic pings to recover lost acks, or deadline/context cancel
250+ pingInterval = fp .opts .ackTimeout / 3
251+ deadline = time .NewTimer (fp .opts .ackTimeout )
252+ ping = time .NewTimer (pingInterval )
253+ return
245254}
246255
247256// buildReplySubject constructs the full reply subject using the cached prefix.
@@ -300,6 +309,7 @@ func (fp *fastPublisher) AddMsg(msg *nats.Msg, opts ...BatchMsgOpt) (*FastPubAck
300309 fp .firstAckCh = firstAckCh
301310 initialErrCh := make (chan error , 1 )
302311 fp .initialErrCh = initialErrCh
312+ fp .stallCh = make (chan struct {}, 1 )
303313
304314 // Publish with reply inbox already set (without holding lock)
305315 if err := fp .js .Conn ().PublishMsg (msg ); err != nil {
@@ -322,7 +332,6 @@ func (fp *fastPublisher) AddMsg(msg *nats.Msg, opts ...BatchMsgOpt) (*FastPubAck
322332 fp .mu .Lock ()
323333 defer fp .mu .Unlock ()
324334
325- fp .flow = firstAck .Messages
326335 fp .batchSubject = msg .Subject
327336 return & FastPubAck {
328337 BatchSequence : fp .sequence ,
@@ -354,12 +363,13 @@ func (fp *fastPublisher) AddMsg(msg *nats.Msg, opts ...BatchMsgOpt) (*FastPubAck
354363
355364 // other than first message, we just publish and track pending acks.
356365 // if we exceed max outstanding acks, we stall until we get a flow ack.
366+ seq := fp .sequence
367+ if err := fp .js .Conn ().PublishMsg (msg ); err != nil {
368+ fp .mu .Unlock ()
369+ return nil , fmt .Errorf ("batch message %d publish failed: %w" , seq , err )
370+ }
357371
358- waitForAck := fp .ackSequence + uint64 (fp .flow )* uint64 (fp .opts .maxOutstandingAcks ) < fp .sequence
359- if waitForAck {
360- if fp .stallCh == nil {
361- fp .stallCh = make (chan struct {}, 1 )
362- }
372+ if fp .waitForAck () {
363373 stallCh := fp .stallCh
364374 fp .mu .Unlock ()
365375 if err := fp .waitForStall (stallCh ); err != nil {
@@ -371,22 +381,20 @@ func (fp *fastPublisher) AddMsg(msg *nats.Msg, opts ...BatchMsgOpt) (*FastPubAck
371381 fp .mu .Lock ()
372382 }
373383
374- // Capture state under lock, then release before the publish I/O
375- // so the ack handler can process responses concurrently.
376- seq := fp .sequence
377384 ackSeq := fp .ackSequence
378385 fp .mu .Unlock ()
379386
380- if err := fp .js .Conn ().PublishMsg (msg ); err != nil {
381- return nil , fmt .Errorf ("batch message %d publish failed: %w" , seq , err )
382- }
383-
384387 return & FastPubAck {
385388 BatchSequence : seq ,
386389 AckSequence : ackSeq ,
387390 }, nil
388391}
389392
393+ // Lock should be held.
394+ func (fp * fastPublisher ) waitForAck () bool {
395+ return fp .ackSequence + uint64 (fp .flow )* uint64 (fp .opts .maxOutstandingAcks ) <= fp .sequence
396+ }
397+
390398// applyBatchMsgOpts processes batch message options and sets the appropriate
391399// headers on the message. Only allocates a header map when opts require it.
392400func applyBatchMsgOpts (msg * nats.Msg , opts []BatchMsgOpt ) error {
@@ -467,21 +475,6 @@ func (fp *fastPublisher) commit(ctx context.Context, msg *nats.Msg, eob bool) (*
467475 }
468476 fp .ackSub = ackSub
469477 }
470- waitForAck := fp .ackSequence + uint64 (fp .flow )* uint64 (fp .opts .maxOutstandingAcks ) < fp .sequence
471- if waitForAck {
472- if fp .stallCh == nil {
473- fp .stallCh = make (chan struct {}, 1 )
474- }
475- stallCh := fp .stallCh
476- fp .mu .Unlock ()
477- if err := fp .waitForStall (stallCh ); err != nil {
478- fp .mu .Lock ()
479- fp .closed = true
480- fp .mu .Unlock ()
481- return nil , fmt .Errorf ("batch commit %w" , err )
482- }
483- fp .mu .Lock ()
484- }
485478 if err := fp .js .Conn ().PublishMsg (msg ); err != nil {
486479 fp .mu .Unlock ()
487480 return nil , fmt .Errorf ("batch commit failed: %w" , err )
@@ -490,29 +483,42 @@ func (fp *fastPublisher) commit(ctx context.Context, msg *nats.Msg, eob bool) (*
490483 // Release lock before waiting for commit response - handler needs lock to send to commitCh
491484 fp .mu .Unlock ()
492485
493- // wait for commit ack or context cancel
486+ deadline , ping , pingInterval := fp .newPingTimers ()
487+ defer deadline .Stop ()
488+ defer ping .Stop ()
489+
494490 var batchAck * BatchAck
495491 var commitErr error
496- select {
497- case commitResp := <- fp .commitCh :
498- if commitResp .Error != nil {
499- commitErr = commitResp .Error
500- break
501- }
502- if commitResp .BatchAck == nil || commitResp .Stream == "" {
503- commitErr = ErrInvalidBatchAck
504- break
505- }
506- batchAck = commitResp .BatchAck
507- case <- ctx .Done ():
508- fp .mu .Lock ()
509- fp .closed = true
510- if fp .ackSub != nil {
511- fp .ackSub .Unsubscribe ()
512- fp .ackSub = nil
492+ for {
493+ select {
494+ case commitResp := <- fp .commitCh :
495+ if commitResp .Error != nil {
496+ commitErr = commitResp .Error
497+ } else if commitResp .BatchAck == nil || commitResp .Stream == "" {
498+ commitErr = ErrInvalidBatchAck
499+ } else {
500+ batchAck = commitResp .BatchAck
501+ }
502+ case <- ping .C :
503+ if err := fp .sendPing (); err != nil {
504+ commitErr = fmt .Errorf ("ping failed: %w" , err )
505+ break
506+ }
507+ ping .Reset (pingInterval )
508+ continue
509+ case <- deadline .C :
510+ commitErr = errors .New ("ack timeout" )
511+ case <- ctx .Done ():
512+ fp .mu .Lock ()
513+ fp .closed = true
514+ if fp .ackSub != nil {
515+ fp .ackSub .Unsubscribe ()
516+ fp .ackSub = nil
517+ }
518+ fp .mu .Unlock ()
519+ return nil , ctx .Err ()
513520 }
514- fp .mu .Unlock ()
515- return nil , ctx .Err ()
521+ break
516522 }
517523
518524 // Reacquire lock to safely modify shared state
@@ -579,7 +585,6 @@ func (fp *fastPublisher) ackMsgHandler(msg *nats.Msg) {
579585 if flowAck != nil {
580586 if fp .flow != flowAck .Messages {
581587 fp .flow = flowAck .Messages
582- fp .buildReplyPrefix ()
583588 }
584589 fp .ackSequence = flowAck .Sequence
585590 // Handle first message ack specially - firstAckCh is only set for first message
@@ -592,10 +597,9 @@ func (fp *fastPublisher) ackMsgHandler(msg *nats.Msg) {
592597
593598 // Handle flow ack. We want to let the publisher know if we are no longer stalled
594599 // and can continue publishing.
595- if fp .stallCh != nil {
596- // Unblock publisher if we were stalled and are now below the max outstanding acks
597- close (fp .stallCh )
598- fp .stallCh = nil
600+ select {
601+ case fp .stallCh <- struct {}{}:
602+ default :
599603 }
600604 }
601605 case bytes .Contains (msg .Data , []byte (`"type":"err"` )):
0 commit comments