Skip to content

Commit 8f23985

Browse files
committed
fix(puller): non-blocking peer disconnect and sync error backoff
When onChange held syncPeersMtx and called disconnectPeer, the inner peer.stop() cancelled per-bin goroutine contexts and then called peer.wg.Wait(). Live goroutines blocked in Sync() → ReadMsgWithContext and only unblocked after pageTimeout (1s) per stream. For N peers disconnecting during a radius decrease, the outer lock was held for up to N×1s, stalling all queued topology-change notifications for the same duration. When syncer.Sync returned a non-fatal error (connection reset, protocol error, stream timeout), the goroutine fell through to limiter.WaitN with count=0 and looped immediately. Any persistent non-fatal error caused a tight CPU spin until the peer disconnected or context was cancelled. Split syncPeer.stop() into cancelBins() (cancel all per-bin contexts, clear the map, no wait) and stop() (cancel + wait, used only in Close()). disconnectPeer now calls cancelBins(): the peer is removed from the sync map immediately and its goroutines drain in the background. Close() already calls p.wg.Wait(), so shutdown correctness is unchanged. Add syncRetryBackoff (1s) with a ctx.Done()-escape after any non-fatal sync error before the next retry. This bounds the retry rate to ≤1/s per goroutine under persistent errors.
1 parent 61fab37 commit 8f23985

2 files changed

Lines changed: 71 additions & 3 deletions

File tree

pkg/puller/puller.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ const (
4242
maxChunksPerSecond = 1000 // roughly 4 MB/s
4343

4444
maxPODelta = 2 // the lowest level of proximity order (of peers) subtracted from the storage radius allowed for chunk syncing.
45+
46+
syncRetryBackoff = time.Second // minimum wait between retries after a non-fatal sync error
4547
)
4648

4749
type Options struct {
@@ -189,12 +191,13 @@ func (p *Puller) manage(ctx context.Context) {
189191
}
190192

191193
// disconnectPeer cancels all existing syncing and removes the peer entry from the syncing map.
194+
// Goroutines drain in the background; the caller is not blocked waiting for them.
192195
// Must be called under lock.
193196
func (p *Puller) disconnectPeer(addr swarm.Address) {
194197
p.logger.Debug("disconnecting peer", "peer_address", addr)
195198
if peer, ok := p.syncPeers[addr.ByteString()]; ok {
196199
peer.mtx.Lock()
197-
peer.stop()
200+
peer.cancelBins()
198201
peer.mtx.Unlock()
199202
}
200203
delete(p.syncPeers, addr.ByteString())
@@ -349,6 +352,11 @@ func (p *Puller) syncPeerBin(parentCtx context.Context, peer *syncPeer, bin uint
349352
return
350353
}
351354
p.logger.Debug("syncWorker interval failed", "error", err, "peer_address", address, "bin", bin, "cursor", cursor, "start", start, "topmost", top)
355+
select {
356+
case <-time.After(syncRetryBackoff):
357+
case <-ctx.Done():
358+
return
359+
}
352360
}
353361

354362
_ = p.limiter.WaitN(ctx, count)
@@ -548,12 +556,19 @@ func newSyncPeer(addr swarm.Address, bins, po uint8) *syncPeer {
548556
}
549557
}
550558

551-
// called when peer disconnects or on shutdown, cleans up ongoing sync operations
552-
func (p *syncPeer) stop() {
559+
// cancelBins cancels all per-bin sync contexts and clears the cancel-func map.
560+
// Does not wait for goroutines to exit. Must be called under peer.mtx.
561+
func (p *syncPeer) cancelBins() {
553562
for bin, c := range p.binCancelFuncs {
554563
c()
555564
delete(p.binCancelFuncs, bin)
556565
}
566+
}
567+
568+
// stop cancels all per-bin sync contexts and waits for goroutines to exit.
569+
// Used on shutdown. Must be called under peer.mtx.
570+
func (p *syncPeer) stop() {
571+
p.cancelBins()
557572
p.wg.Wait()
558573
}
559574

pkg/puller/puller_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,59 @@ func TestContinueSyncing(t *testing.T) {
507507
}
508508
}
509509

510+
// TestSyncErrorBackoff verifies that a non-fatal sync error is followed by a
511+
// backoff before the next retry, bounding the retry rate to roughly 1/s.
512+
func TestSyncErrorBackoff(t *testing.T) {
513+
t.Parallel()
514+
515+
addr := swarm.RandAddress(t)
516+
517+
// Use Topmost=0 so that top < start and the interval is never advanced,
518+
// causing the loop to retry with the same start value each time.
519+
// Provide two replies so we can observe two successive sync calls.
520+
_, _, kad, ps := newPuller(t, opts{
521+
kad: []kadMock.Option{
522+
kadMock.WithEachPeerRevCalls(kadMock.AddrTuple{Addr: addr, PO: 0}),
523+
},
524+
pullSync: []mockps.Option{
525+
mockps.WithCursors([]uint64{100}, 0),
526+
mockps.WithSyncError(errors.New("stream error")),
527+
mockps.WithReplies(
528+
mockps.SyncReply{Bin: 0, Start: 1, Topmost: 0, Peer: addr},
529+
mockps.SyncReply{Bin: 0, Start: 1, Topmost: 0, Peer: addr},
530+
),
531+
},
532+
bins: 1,
533+
rs: resMock.NewReserve(resMock.WithRadius(0)),
534+
})
535+
536+
time.Sleep(100 * time.Millisecond)
537+
kad.Trigger()
538+
539+
// wait for the first call
540+
err := spinlock.Wait(2*time.Second, func() bool {
541+
return len(ps.SyncCalls(addr)) >= 1
542+
})
543+
if err != nil {
544+
t.Fatal("timed out waiting for first sync call")
545+
}
546+
t1 := time.Now()
547+
548+
// wait for the second call — must be separated by at least syncRetryBackoff
549+
err = spinlock.Wait(3*time.Second, func() bool {
550+
return len(ps.SyncCalls(addr)) >= 2
551+
})
552+
if err != nil {
553+
t.Fatal("timed out waiting for second sync call")
554+
}
555+
elapsed := time.Since(t1)
556+
557+
const minBackoff = 800 * time.Millisecond // allow 200ms tolerance below syncRetryBackoff
558+
if elapsed < minBackoff {
559+
t.Fatalf("retry happened too fast: elapsed %v, want >= %v", elapsed, minBackoff)
560+
}
561+
}
562+
510563
func TestPeerGone(t *testing.T) {
511564
t.Parallel()
512565

0 commit comments

Comments
 (0)