Skip to content

Commit 1a88740

Browse files
committed
fix(pullsync): document cursor boundary invariants and add tests
1 parent d5b6341 commit 1a88740

2 files changed

Lines changed: 91 additions & 0 deletions

File tree

pkg/pullsync/pullsync.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,11 @@ func (s *Syncer) collectAddrs(ctx context.Context, bin uint8, start uint64) ([]*
453453
if int(bin) < len(cursors) {
454454
historicalCursor = cursors[bin]
455455
}
456+
// If bin is beyond the cursors slice (should not happen with a
457+
// well-behaved storer that always returns swarm.MaxBins entries),
458+
// historicalCursor stays 0. The cursor cap below then sets
459+
// topmost=0 for any non-empty offer, so the client never advances
460+
// its interval — a safe stall until the storer is consistent again.
456461

457462
var (
458463
chs []*storer.BinC
@@ -538,6 +543,11 @@ func (s *Syncer) collectAddrs(ctx context.Context, bin uint8, start uint64) ([]*
538543
// The client stores every chunk in one round trip; subsequent round
539544
// trips advance the interval bookkeeping via leading-gap empty offers
540545
// and empty Want bitvectors — no chunk data is retransmitted.
546+
// The start > 0 guard matches the leading-gap check above: BinIDs
547+
// start at 1 in production and the puller always passes start >= 1.
548+
// For start=0 (used only in test helpers) contiguousEnd is ambiguous —
549+
// uint64 underflow prevents the start-1 initialisation — so the cap
550+
// is skipped and topmost is left at the historical cursor.
541551
if start > 0 && len(chs) > 0 && contiguousEnd >= start && contiguousEnd < topmost {
542552
topmost = contiguousEnd
543553
}

pkg/pullsync/pullsync_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,87 @@ func TestSync_OverwriteNewerChunkDoesNotBlockInterval(t *testing.T) {
526526
})
527527
}
528528

529+
// TestSync_Start0_SkipsGapDetection confirms that when start=0 the leading-gap
530+
// check and the contiguous-frontier cap are both skipped (both are guarded by
531+
// start > 0). Even though the server's first chunk is at BinID=5, the offer is
532+
// non-empty and topmost equals the historical cursor.
533+
func TestSync_Start0_SkipsGapDetection(t *testing.T) {
534+
synctest.Test(t, func(t *testing.T) {
535+
ch := testingc.GenerateTestRandomChunk()
536+
stampHash, err := ch.Stamp().Hash()
537+
if err != nil {
538+
t.Fatal(err)
539+
}
540+
const binID = uint64(5)
541+
result := []*storer.BinC{{
542+
Address: ch.Address(),
543+
BatchID: ch.Stamp().BatchID(),
544+
BinID: binID,
545+
StampHash: stampHash,
546+
}}
547+
548+
var (
549+
ps, _ = newPullSync(t, nil, 10, mock.WithSubscribeResp(result, nil), mock.WithChunks(ch), mock.WithCursors([]uint64{binID}, 0))
550+
recorder = streamtest.New(streamtest.WithProtocols(ps.Protocol()))
551+
psClient, _ = newPullSync(t, recorder, 0)
552+
)
553+
554+
topmost, count, err := psClient.Sync(context.Background(), swarm.ZeroAddress, 0, 0)
555+
if err != nil {
556+
t.Fatal(err)
557+
}
558+
// Neither gap check fires for start=0; topmost must equal the cursor.
559+
if topmost != binID {
560+
t.Fatalf("topmost: got %d, want %d (gap detection must be skipped for start=0)", topmost, binID)
561+
}
562+
if count != 1 {
563+
t.Fatalf("count: got %d, want 1", count)
564+
}
565+
})
566+
}
567+
568+
// TestSync_BinBeyondCursors_StallsInterval verifies the safe-stall behaviour
569+
// when bin is beyond the cursors slice returned by ReserveLastBinIDs.
570+
// historicalCursor defaults to 0, so the cursor cap sets topmost=0 for any
571+
// non-empty offer. The client stores the chunk but does not advance the
572+
// interval (top=0 < start=1 in the puller guard).
573+
func TestSync_BinBeyondCursors_StallsInterval(t *testing.T) {
574+
synctest.Test(t, func(t *testing.T) {
575+
ch := testingc.GenerateTestRandomChunk()
576+
stampHash, err := ch.Stamp().Hash()
577+
if err != nil {
578+
t.Fatal(err)
579+
}
580+
result := []*storer.BinC{{
581+
Address: ch.Address(),
582+
BatchID: ch.Stamp().BatchID(),
583+
BinID: 1,
584+
StampHash: stampHash,
585+
}}
586+
587+
// Cursors slice has only one entry (bin=0). Syncing bin=1 leaves
588+
// historicalCursor=0, triggering the safe-stall path.
589+
var (
590+
ps, _ = newPullSync(t, nil, 10, mock.WithSubscribeResp(result, nil), mock.WithChunks(ch), mock.WithCursors([]uint64{10}, 0))
591+
recorder = streamtest.New(streamtest.WithProtocols(ps.Protocol()))
592+
psClient, _ = newPullSync(t, recorder, 0)
593+
)
594+
595+
topmost, count, err := psClient.Sync(context.Background(), swarm.ZeroAddress, 1, 1)
596+
if err != nil {
597+
t.Fatal(err)
598+
}
599+
// historicalCursor=0 for bin=1 → cursor cap fires → topmost=0.
600+
if topmost != 0 {
601+
t.Fatalf("topmost: got %d, want 0 (bin beyond cursors must stall interval)", topmost)
602+
}
603+
// The chunk is still stored; only interval advancement is suppressed.
604+
if count != 1 {
605+
t.Fatalf("count: got %d, want 1 (chunk must be stored despite topmost=0)", count)
606+
}
607+
})
608+
}
609+
529610
func haveChunks(t *testing.T, s *mock.ReserveStore, chunks ...swarm.Chunk) {
530611
t.Helper()
531612
for _, c := range chunks {

0 commit comments

Comments
 (0)