Skip to content

Commit 2a32303

Browse files
jetstreamext: fix FastPublisher stall, timeout, and reply prefix bugs (#57)
Signed-off-by: Maurice van Veen <github@mauricevanveen.com>
1 parent 52a3031 commit 2a32303

4 files changed

Lines changed: 319 additions & 76 deletions

File tree

jetstreamext/fastpublish.go

Lines changed: 72 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
214221
func (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.
392400
func 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"`)):

jetstreamext/publishbatch.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,13 @@ type (
107107
BatchID string `json:"batch,omitempty"`
108108

109109
// BatchSize is the number of messages in the batch.
110-
BatchSize int `json:"count,omitempty"`
110+
BatchSize uint64 `json:"count,omitempty"`
111111
}
112112

113113
batchPublisher struct {
114114
js jetstream.JetStream
115115
batchID string
116-
sequence int
116+
sequence uint64
117117
closed bool
118118
opts batchPublishOpts
119119
mu sync.Mutex
@@ -222,13 +222,13 @@ func (b *batchPublisher) AddMsg(msg *nats.Msg, opts ...BatchMsgOpt) error {
222222

223223
b.sequence++
224224
msg.Header.Set(BatchIDHeader, b.batchID)
225-
msg.Header.Set(BatchSeqHeader, strconv.FormatUint(uint64(b.sequence), 10))
225+
msg.Header.Set(BatchSeqHeader, strconv.FormatUint(b.sequence, 10))
226226

227227
// Determine if we need flow control for this message
228228
var needsAck bool
229229
if b.opts.flowControl.AckFirst && b.sequence == 1 {
230230
needsAck = true // wait on first message
231-
} else if b.opts.flowControl.AckEvery > 0 && b.sequence%b.opts.flowControl.AckEvery == 0 {
231+
} else if b.opts.flowControl.AckEvery > 0 && b.sequence%uint64(b.opts.flowControl.AckEvery) == 0 {
232232
needsAck = true // periodic flow control
233233
}
234234

@@ -306,7 +306,7 @@ func (b *batchPublisher) CommitMsg(ctx context.Context, msg *nats.Msg, opts ...B
306306
b.sequence++
307307

308308
msg.Header.Set(BatchIDHeader, b.batchID)
309-
msg.Header.Set(BatchSeqHeader, strconv.FormatUint(uint64(b.sequence), 10))
309+
msg.Header.Set(BatchSeqHeader, strconv.FormatUint(b.sequence, 10))
310310
msg.Header.Set(BatchCommitHeader, "1")
311311

312312
ctx, cancel = wrapContextWithoutDeadline(ctx, b.js)
@@ -333,7 +333,7 @@ func (b *batchPublisher) CommitMsg(ctx context.Context, msg *nats.Msg, opts ...B
333333
return nil, batchResp.Error
334334
}
335335
if batchResp.BatchAck == nil || batchResp.Stream == "" ||
336-
batchResp.BatchID != b.batchID || batchResp.BatchSize != int(b.sequence) {
336+
batchResp.BatchID != b.batchID || batchResp.BatchSize != b.sequence {
337337
return nil, ErrInvalidBatchAck
338338
}
339339

@@ -355,6 +355,8 @@ func (b *batchPublisher) Discard() error {
355355
}
356356

357357
// Size returns the number of messages added to the batch so far.
358+
// Note: the return type is int to satisfy the BatchPublisher interface; the internal counter is
359+
// uint64, so batches exceeding math.MaxInt will be reported incorrectly.
358360
func (b *batchPublisher) Size() int {
359361
b.mu.Lock()
360362
defer b.mu.Unlock()
@@ -460,7 +462,7 @@ func PublishMsgBatch(ctx context.Context, js jetstream.JetStream, messages []*na
460462
return nil, batchResp.Error
461463
}
462464
if batchResp.BatchAck == nil || batchResp.Stream == "" ||
463-
batchResp.BatchID != batchID || batchResp.BatchSize != msgs {
465+
batchResp.BatchID != batchID || batchResp.BatchSize != uint64(msgs) {
464466

465467
return nil, ErrInvalidBatchAck
466468
}

0 commit comments

Comments
 (0)