Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions pkg/hive/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import (
"github.com/ethersphere/bee/v2/pkg/hive/pb"
)

var MaxBatchSize = maxBatchSize
var LimitBurst = limitBurst
var (
MaxBatchSize = maxBatchSize
LimitBurst = limitBurst
CoalesceThreshold = coalesceThreshold
)

func (s *Service) SetTimeFunc(f func() time.Time) {
s.now = f
Expand Down
104 changes: 104 additions & 0 deletions pkg/hive/gossip_buffer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright 2026 The Swarm Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package hive

import (
"maps"
"slices"
"sync"
"time"

"github.com/ethersphere/bee/v2/pkg/swarm"
)

const (
defaultGossipCoalesceInterval = 5 * time.Second
// coalesceThreshold: gossips with fewer peers are buffered; larger
// (already-batched) messages are dispatched immediately.
coalesceThreshold = 5
)

// gossipBuffer accumulates single-peer outbound gossip per addressee so it can be
// flushed as one batched message.
type gossipBuffer struct {
mu sync.Mutex
pending map[string]map[string]swarm.Address // addressee key -> peer key -> peer
interval time.Duration
maxBatch int
}

type gossipBatch struct {
addressee swarm.Address
peers []swarm.Address
}

func newGossipBuffer(interval time.Duration, maxBatch int) *gossipBuffer {
if interval == 0 {
interval = defaultGossipCoalesceInterval
}
return &gossipBuffer{
pending: make(map[string]map[string]swarm.Address),
interval: interval,
maxBatch: maxBatch,
}
}

// stagePeers buffers peers for the addressee. If the buffer reaches maxBatch it is
// removed and returned so the caller can flush it immediately. A new addressee is
// not written to pending if the merged set already meets maxBatch.
func (b *gossipBuffer) stagePeers(addressee swarm.Address, peers ...swarm.Address) (flushPeers []swarm.Address, flush bool) {
b.mu.Lock()
defer b.mu.Unlock()

key := addressee.ByteString()
peerSet, exist := b.pending[key]
if !exist {
peerSet = make(map[string]swarm.Address)
}
for _, p := range peers {
peerSet[p.ByteString()] = p
}
if len(peerSet) >= b.maxBatch {
if exist {
delete(b.pending, key)
}
return slices.Collect(maps.Values(peerSet)), true
}

b.pending[key] = peerSet
return nil, false
}

// takeAll removes and returns all buffered entries.
func (b *gossipBuffer) takeAll() []gossipBatch {
b.mu.Lock()
defer b.mu.Unlock()

if len(b.pending) == 0 {
return nil
}

out := make([]gossipBatch, 0, len(b.pending))
for key, peerSet := range b.pending {
out = append(out, gossipBatch{
addressee: swarm.NewAddress([]byte(key)),
peers: slices.Collect(maps.Values(peerSet)),
})
}
clear(b.pending)
return out
}

func (b *gossipBuffer) clearAddressee(addressee swarm.Address) {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.pending, addressee.ByteString())
}

func (b *gossipBuffer) pendingAddressees() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.pending)
}
87 changes: 87 additions & 0 deletions pkg/hive/gossip_buffer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright 2026 The Swarm Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package hive

import (
"testing"
"time"

"github.com/ethersphere/bee/v2/pkg/swarm"
)

func TestGossipBufferAddAndTakeAll(t *testing.T) {
t.Parallel()

b := newGossipBuffer(time.Second, maxBatchSize)
addressee := swarm.RandAddress(t)
peer1 := swarm.RandAddress(t)
peer2 := swarm.RandAddress(t)

if pending := b.takeAll(); len(pending) != 0 {
t.Fatalf("want no pending entries, got %d", len(pending))
}

if _, flush := b.stagePeers(addressee, peer1); flush {
t.Fatal("unexpected immediate flush")
}

if _, flush := b.stagePeers(addressee, peer2); flush {
t.Fatal("unexpected immediate flush")
}

pending := b.takeAll()
if len(pending) != 1 {
t.Fatalf("want 1 pending entry, got %d", len(pending))
}
if got := len(pending[0].peers); got != 2 {
t.Fatalf("want 2 coalesced peers, got %d", got)
}
if !pending[0].addressee.Equal(addressee) {
t.Fatal("unexpected addressee in pending batch")
}

if pending := b.takeAll(); len(pending) != 0 {
t.Fatalf("want empty buffer after takeAll, got %d pending", len(pending))
}
}

func TestGossipBufferMaxBatchFlush(t *testing.T) {
t.Parallel()

b := newGossipBuffer(time.Second, 2)
addressee := swarm.RandAddress(t)

b.stagePeers(addressee, swarm.RandAddress(t))
flushPeers, flush := b.stagePeers(addressee, swarm.RandAddress(t))
if !flush {
t.Fatal("want immediate flush at maxBatch")
}
if got := len(flushPeers); got != 2 {
t.Fatalf("want 2 peers in full batch, got %d", got)
}
if pending := b.takeAll(); len(pending) != 0 {
t.Fatalf("want empty buffer after maxBatch flush, got %d pending", len(pending))
}
}

func TestGossipBufferMaxBatchFlushWithoutPendingInsert(t *testing.T) {
t.Parallel()

b := newGossipBuffer(time.Second, 2)
addressee := swarm.RandAddress(t)
peer1 := swarm.RandAddress(t)
peer2 := swarm.RandAddress(t)

flushPeers, flush := b.stagePeers(addressee, peer1, peer2)
if !flush {
t.Fatal("want immediate flush when first insert meets maxBatch")
}
if got := len(flushPeers); got != 2 {
t.Fatalf("want 2 peers in full batch, got %d", got)
}
if pending := b.takeAll(); len(pending) != 0 {
t.Fatalf("want no pending insert after maxBatch flush, got %d pending", len(pending))
}
}
Loading
Loading