Skip to content

Commit 4f9650f

Browse files
committed
fix(pullsync): prevent interval advancement past unverified BinIDs
Two correctness bugs are fixed. When a delivered chunk fails stamp validation, the peer's sync interval must not advance past that BinID. Previously, Sync() returned offer.Topmost unconditionally; the puller marked those BinIDs as done even though no chunk was stored. Now Sync() returns topmost=0 on any validation failure (invalid stamp, unsolicited, or structural error), so the puller's existing guard suppresses the interval write. ErrOverwriteNewerChunk is excluded: the chunk is already in the reserve with a newer stamp, so advancing past it is correct. collectAddrs blocked on SubscribeBin until the first chunk arrived. A live chunk at BinID X far beyond the server's historical frontier caused offer.Topmost=X, inflating the client's interval past historical BinIDs it had never received. collectAddrs now snapshots ReserveLastBinIDs() before subscribing and caps topmost at that cursor. Live chunks are still delivered; only Topmost is bounded to the historical frontier. Sync() retains its 3-value signature. topmost is the server's watermark (capped at the historical cursor) on success, and 0 on validation failure. The puller code is unchanged.
1 parent 7429496 commit 4f9650f

4 files changed

Lines changed: 186 additions & 77 deletions

File tree

pkg/puller/puller.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ func (p *Puller) syncPeerBin(parentCtx context.Context, peer *syncPeer, bin uint
334334

335335
p.metrics.SyncWorkerIterCounter.Inc()
336336

337-
top, stored, count, err := p.syncer.Sync(ctx, address, bin, start)
337+
top, count, err := p.syncer.Sync(ctx, address, bin, start)
338338

339339
if top == math.MaxUint64 {
340340
p.metrics.MaxUintErrCounter.Inc()
@@ -360,14 +360,15 @@ func (p *Puller) syncPeerBin(parentCtx context.Context, peer *syncPeer, bin uint
360360
p.metrics.SyncedCounter.WithLabelValues("live").Add(float64(count))
361361
}
362362

363-
// advance interval only when chunks were successfully stored
364-
if stored >= start {
365-
if err := p.addPeerInterval(address, bin, start, stored); err != nil {
363+
// top is 0 on validation error (see pullsync.Sync), so this check
364+
// prevents interval advancement when chunks were not successfully stored.
365+
if top >= start {
366+
if err := p.addPeerInterval(address, bin, start, top); err != nil {
366367
p.metrics.SyncWorkerErrCounter.Inc()
367368
p.logger.Error(err, "syncWorker could not persist interval for peer, quitting", "peer_address", address)
368369
return
369370
}
370-
start = stored + 1
371+
start = top + 1
371372
}
372373
}
373374
}

pkg/pullsync/mock/pullsync.go

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,11 @@ func toID(a swarm.Address, bin uint8, start uint64) string {
4040
return fmt.Sprintf("%s-%d-%d", a, bin, start)
4141
}
4242

43-
// SyncReply configures a single response from PullSyncMock.Sync.
44-
// Stored overrides the stored return value; if zero, stored defaults to
45-
// Topmost (matching successful-delivery behaviour). Set Stored explicitly
46-
// when testing scenarios where stored must differ from Topmost.
4743
type SyncReply struct {
4844
Peer swarm.Address
4945
Bin uint8
5046
Start uint64
5147
Topmost uint64
52-
Stored uint64 // 0 means "default to Topmost"; set explicitly to test partial/failed delivery
5348
Count int
5449
}
5550

@@ -76,7 +71,7 @@ func NewPullSync(opts ...Option) *PullSyncMock {
7671
return s
7772
}
7873

79-
func (p *PullSyncMock) Sync(ctx context.Context, peer swarm.Address, bin uint8, start uint64) (topmost uint64, stored uint64, count int, err error) {
74+
func (p *PullSyncMock) Sync(ctx context.Context, peer swarm.Address, bin uint8, start uint64) (topmost uint64, count int, err error) {
8075

8176
p.mtx.Lock()
8277

@@ -88,19 +83,16 @@ func (p *PullSyncMock) Sync(ctx context.Context, peer swarm.Address, bin uint8,
8883
p.replies[id] = p.replies[id][1:]
8984
p.syncCalls = append(p.syncCalls, reply)
9085
p.mtx.Unlock()
91-
// mirror real Sync behaviour: stored defaults to Topmost on success
92-
s := reply.Topmost
93-
if reply.Stored != 0 {
94-
s = reply.Stored
95-
}
86+
// mirror real Sync: topmost is 0 on error so the interval is not advanced
87+
top := reply.Topmost
9688
if p.syncErr != nil {
97-
s = 0
89+
top = 0
9890
}
99-
return reply.Topmost, s, reply.Count, p.syncErr
91+
return top, reply.Count, p.syncErr
10092
}
10193
p.mtx.Unlock()
10294
<-ctx.Done()
103-
return 0, 0, 0, ctx.Err()
95+
return 0, 0, ctx.Err()
10496
}
10597

10698
func (p *PullSyncMock) GetCursors(_ context.Context, peer swarm.Address) ([]uint64, uint64, error) {

pkg/pullsync/pullsync.go

Lines changed: 69 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,15 @@ const (
5656
// Interface is the PullSync interface.
5757
type Interface interface {
5858
// Sync syncs a batch of chunks starting at a start BinID.
59-
// It returns the BinID of the highest offered chunk (topmost), the BinID
60-
// of the highest chunk that was actually stored (stored; 0 on any delivery
61-
// error), and the total number of chunks successfully stored.
62-
// Callers must use stored — not topmost — to advance their sync interval,
63-
// so that BinIDs whose chunks failed validation are not silently skipped.
64-
// topmost is retained for overflow detection and future HIST-completion use.
65-
Sync(ctx context.Context, peer swarm.Address, bin uint8, start uint64) (topmost uint64, stored uint64, count int, err error)
59+
// It returns the topmost BinID safe for interval advancement and the total
60+
// number of chunks successfully stored.
61+
// topmost equals offer.Topmost (capped at the server's historical cursor)
62+
// when all delivered chunks passed validation. topmost is 0 when any chunk
63+
// failed validation (invalid stamp, unsolicited, or structural error), so
64+
// callers that use topmost to advance their interval will not skip
65+
// unverified BinIDs. ErrOverwriteNewerChunk does not zero topmost because
66+
// the chunk is already present in the reserve.
67+
Sync(ctx context.Context, peer swarm.Address, bin uint8, start uint64) (topmost uint64, count int, err error)
6668
// GetCursors retrieves all cursors from a downstream peer.
6769
GetCursors(ctx context.Context, peer swarm.Address) ([]uint64, uint64, error)
6870
}
@@ -229,16 +231,17 @@ func (s *Syncer) handler(streamCtx context.Context, p p2p.Peer, stream p2p.Strea
229231
}
230232

231233
// Sync syncs a batch of chunks starting at a start BinID.
232-
// topmost is the highest BinID offered by the peer (used for overflow detection).
233-
// stored is the highest BinID that was successfully validated and stored
234-
// (0 if any chunk failed validation); callers must advance their sync interval
235-
// using stored, not topmost.
236-
// count is the number of chunks successfully stored.
237-
func (s *Syncer) Sync(ctx context.Context, peer swarm.Address, bin uint8, start uint64) (topmost uint64, stored uint64, count int, err error) {
234+
// topmost equals offer.Topmost capped at the server's historical cursor
235+
// (see collectAddrs). topmost is 0 when any delivered chunk failed validation
236+
// (invalid stamp, unsolicited, or structural error), preventing the caller
237+
// from silently skipping unverified BinIDs.
238+
// ErrOverwriteNewerChunk does not zero topmost: the chunk is already present.
239+
// count is the number of chunks successfully written to the reserve.
240+
func (s *Syncer) Sync(ctx context.Context, peer swarm.Address, bin uint8, start uint64) (topmost uint64, count int, err error) {
238241

239242
stream, err := s.streamer.NewStream(ctx, peer, nil, protocolName, protocolVersion, streamName)
240243
if err != nil {
241-
return 0, 0, 0, fmt.Errorf("new stream: %w", err)
244+
return 0, 0, fmt.Errorf("new stream: %w", err)
242245
}
243246
defer func() {
244247
if err != nil {
@@ -252,20 +255,18 @@ func (s *Syncer) Sync(ctx context.Context, peer swarm.Address, bin uint8, start
252255

253256
rangeMsg := &pb.Get{Bin: int32(bin), Start: start}
254257
if err = w.WriteMsgWithContext(ctx, rangeMsg); err != nil {
255-
return 0, 0, 0, fmt.Errorf("write get range: %w", err)
258+
return 0, 0, fmt.Errorf("write get range: %w", err)
256259
}
257260

258261
var offer pb.Offer
259262
if err = r.ReadMsgWithContext(ctx, &offer); err != nil {
260-
return 0, 0, 0, fmt.Errorf("read offer: %w", err)
263+
return 0, 0, fmt.Errorf("read offer: %w", err)
261264
}
262265

263-
// empty interval (no chunks present in interval).
264-
// return the end of the requested range as topmost.
265-
// stored equals topmost: an empty offer advances the interval to the peer's
266-
// watermark without delivering any chunks, which is correct.
266+
// empty interval: no chunks in range.
267+
// return the peer's watermark so the caller can advance the interval.
267268
if len(offer.Chunks) == 0 {
268-
return offer.Topmost, offer.Topmost, 0, nil
269+
return offer.Topmost, 0, nil
269270
}
270271

271272
topmost = offer.Topmost
@@ -279,7 +280,7 @@ func (s *Syncer) Sync(ctx context.Context, peer swarm.Address, bin uint8, start
279280

280281
bv, err := bitvector.New(bvLen)
281282
if err != nil {
282-
return 0, 0, 0, fmt.Errorf("new bitvector: %w", err)
283+
return 0, 0, fmt.Errorf("new bitvector: %w", err)
283284
}
284285

285286
for i := 0; i < len(offer.Chunks); i++ {
@@ -288,7 +289,7 @@ func (s *Syncer) Sync(ctx context.Context, peer swarm.Address, bin uint8, start
288289
batchID := offer.Chunks[i].BatchID
289290
stampHash := offer.Chunks[i].StampHash
290291
if len(addr) != swarm.HashSize {
291-
return 0, 0, 0, fmt.Errorf("inconsistent hash length")
292+
return 0, 0, fmt.Errorf("inconsistent hash length")
292293
}
293294

294295
a := swarm.NewAddress(addr)
@@ -302,7 +303,7 @@ func (s *Syncer) Sync(ctx context.Context, peer swarm.Address, bin uint8, start
302303
have, err = s.store.ReserveHas(a, batchID, stampHash)
303304
if err != nil {
304305
s.logger.Debug("storage has", "error", err)
305-
return 0, 0, 0, err
306+
return 0, 0, err
306307
}
307308

308309
if !have {
@@ -316,16 +317,19 @@ func (s *Syncer) Sync(ctx context.Context, peer swarm.Address, bin uint8, start
316317

317318
wantMsg := &pb.Want{BitVector: bv.Bytes()}
318319
if err = w.WriteMsgWithContext(ctx, wantMsg); err != nil {
319-
return 0, 0, 0, fmt.Errorf("write want: %w", err)
320+
return 0, 0, fmt.Errorf("write want: %w", err)
320321
}
321322

322323
chunksToPut := make([]swarm.Chunk, 0, ctr)
323324

324-
var chunkErr error
325+
var (
326+
chunkErr error
327+
hasValidationError bool // true when a chunk was not stored due to a validation failure
328+
)
325329
for ; ctr > 0; ctr-- {
326330
var delivery pb.Delivery
327331
if err = r.ReadMsgWithContext(ctx, &delivery); err != nil {
328-
return 0, 0, 0, errors.Join(chunkErr, fmt.Errorf("read delivery: %w", err))
332+
return 0, 0, errors.Join(chunkErr, fmt.Errorf("read delivery: %w", err))
329333
}
330334

331335
addr := swarm.NewAddress(delivery.Address)
@@ -340,18 +344,21 @@ func (s *Syncer) Sync(ctx context.Context, peer swarm.Address, bin uint8, start
340344
stamp := new(postage.Stamp)
341345
if err = stamp.UnmarshalBinary(delivery.Stamp); err != nil {
342346
chunkErr = errors.Join(chunkErr, err)
347+
hasValidationError = true
343348
continue
344349
}
345350
stampHash, err := stamp.Hash()
346351
if err != nil {
347352
chunkErr = errors.Join(chunkErr, err)
353+
hasValidationError = true
348354
continue
349355
}
350356

351357
wantChunkID := addr.ByteString() + string(stamp.BatchID()) + string(stampHash)
352358
if _, ok := wantChunks[wantChunkID]; !ok {
353359
s.logger.Debug("want chunks", "error", ErrUnsolicitedChunk, "peer_address", peer, "chunk_address", addr)
354360
chunkErr = errors.Join(chunkErr, ErrUnsolicitedChunk)
361+
hasValidationError = true
355362
continue
356363
}
357364

@@ -361,6 +368,7 @@ func (s *Syncer) Sync(ctx context.Context, peer swarm.Address, bin uint8, start
361368
if err != nil {
362369
s.logger.Debug("unverified stamp", "error", err, "peer_address", peer, "chunk_address", newChunk)
363370
chunkErr = errors.Join(chunkErr, err)
371+
hasValidationError = true
364372
continue
365373
}
366374

@@ -370,13 +378,15 @@ func (s *Syncer) Sync(ctx context.Context, peer swarm.Address, bin uint8, start
370378
addr, err := chunk.Address()
371379
if err != nil {
372380
chunkErr = errors.Join(chunkErr, err)
381+
hasValidationError = true
373382
continue
374383
}
375384
s.logger.Debug("sync gsoc", "peer_address", peer, "chunk_address", addr, "wrapped_chunk_address", chunk.WrappedChunk().Address())
376385
s.gsocHandler(chunk)
377386
} else {
378387
s.logger.Debug("invalid cac/soc chunk", "error", swarm.ErrInvalidChunk, "peer_address", peer, "chunk", chunk)
379388
chunkErr = errors.Join(chunkErr, swarm.ErrInvalidChunk)
389+
hasValidationError = true
380390
s.metrics.ReceivedInvalidChunk.Inc()
381391
continue
382392
}
@@ -396,23 +406,27 @@ func (s *Syncer) Sync(ctx context.Context, peer swarm.Address, bin uint8, start
396406
if errors.Is(err, storage.ErrOverwriteNewerChunk) {
397407
s.logger.Debug("overwrite newer chunk", "error", err, "peer_address", peer, "chunk", c)
398408
chunkErr = errors.Join(chunkErr, err)
409+
// ErrOverwriteNewerChunk is not a validation failure: the chunk is
410+
// already present in the reserve with a newer stamp. It is safe to
411+
// advance the interval past it.
399412
continue
400413
}
401-
return 0, 0, 0, errors.Join(chunkErr, err)
414+
return 0, 0, errors.Join(chunkErr, err)
402415
}
403416
chunksPut++
404417
}
405418
}
406419

407-
// stored is the BinID callers should use to advance their sync interval.
408-
// When any chunk failed validation or storage, stored is 0 so the interval
409-
// does not advance past unverified BinIDs.
410-
stored = topmost
411-
if chunkErr != nil {
412-
stored = 0
420+
// Zero topmost when any chunk failed validation (stamp, solicitation,
421+
// or structural check). This prevents the caller from advancing its
422+
// interval past BinIDs whose chunks were never stored.
423+
// ErrOverwriteNewerChunk does not zero topmost: the chunk is already
424+
// present in the reserve with a newer stamp, so advancement is safe.
425+
if hasValidationError {
426+
topmost = 0
413427
}
414428

415-
return topmost, stored, chunksPut, chunkErr
429+
return topmost, chunksPut, chunkErr
416430
}
417431

418432
// makeOffer tries to assemble an offer for a given requested interval.
@@ -443,6 +457,19 @@ type collectAddrsResult struct {
443457
// after which the function returns the collected slice of chunks.
444458
func (s *Syncer) collectAddrs(ctx context.Context, bin uint8, start uint64) ([]*storer.BinC, uint64, error) {
445459
v, _, err := s.intervalsSF.Do(ctx, sfKey(bin, start), func(ctx context.Context) (*collectAddrsResult, error) {
460+
// Snapshot the server's historical cursor for this bin before subscribing.
461+
// Any chunk with BinID beyond this cursor arrived live after the snapshot;
462+
// capping topmost at the cursor prevents live chunks from inflating the
463+
// interval the client records for this peer.
464+
cursors, _, err := s.store.ReserveLastBinIDs()
465+
if err != nil {
466+
return nil, fmt.Errorf("reserve last bin IDs: %w", err)
467+
}
468+
var historicalCursor uint64
469+
if int(bin) < len(cursors) {
470+
historicalCursor = cursors[bin]
471+
}
472+
446473
var (
447474
chs []*storer.BinC
448475
topmost uint64
@@ -492,6 +519,13 @@ func (s *Syncer) collectAddrs(ctx context.Context, bin uint8, start uint64) ([]*
492519
}
493520
}
494521

522+
// Cap topmost at the historical cursor. Live chunks (BinID > historicalCursor)
523+
// are included in the offer so the client can store them, but Topmost must
524+
// not advance the client's interval past the server's historical frontier.
525+
if topmost > historicalCursor {
526+
topmost = historicalCursor
527+
}
528+
495529
return &collectAddrsResult{chs: chs, topmost: topmost}, nil
496530
})
497531
if err != nil {

0 commit comments

Comments
 (0)