Skip to content

feat: gossip improvements - #5520

Open
sbackend123 wants to merge 16 commits into
masterfrom
feat/gossip-improvements
Open

feat: gossip improvements#5520
sbackend123 wants to merge 16 commits into
masterfrom
feat/gossip-improvements

Conversation

@sbackend123

@sbackend123 sbackend123 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Checklist

  • I have read the coding guide.
  • My change requires a documentation update, and I have done it.
  • I have added tests to cover my changes.
  • I have filled out the description and linked the related issues.

Description

Adds write coalescing for hive outbound gossip. Single-peer BroadcastPeers calls are buffered per addressee and flushed as one batched message after ~1s (configurable via GossipCoalesceInterval), or immediately when the buffer reaches maxBatchSize (30). Calls with 2+ peers are sent without coalescing

Open API Spec Version Changes (if applicable)

Motivation and Context (Optional)

Related Issue (Optional)

#5490

Screenshots (if appropriate):

AI Disclosure

  • This PR contains code that has been generated by an LLM.
  • I have reviewed the AI generated code thoroughly.
  • I possess the technical expertise to responsibly review the code generated in this PR.

sbackend123 and others added 3 commits June 29, 2026 11:48
Discard buffered gossip on shutdown instead of flushing, restore quit
checks in broadcastNow, document the async BroadcastPeers contract, use
fixed 100ms jitter, and clean up tests and metrics.

Co-authored-by: Cursor <cursoragent@cursor.com>
@sbackend123
sbackend123 marked this pull request as ready for review June 29, 2026 12:43
Comment thread pkg/hive/hive.go Outdated
Comment thread pkg/hive/gossip_buffer.go Outdated
Comment thread pkg/hive/gossip_buffer.go Outdated
Comment thread pkg/hive/gossip_buffer.go Outdated
type pendingGossip struct {
addressee swarm.Address
peers map[string]swarm.Address // peer bytestring -> address (set semantics)
deadline time.Time

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wonder whether there's a benefit of keeping a deadline per peer. usually, write coalescing is simple enough:

  • have an interval fire at a constant rate
  • if new records arrived by interval fire
  • when new entries arrived, optionally, postpone the sending the sending until the next interval firing (and so also you could extend up to a set upper bound, so that entries don't keep collecting forever but also guarantee that information goes out still relatively quickly)
  • send all the pending sends

also: usually, when a peer arrives - we gossip that peer to all peers (full nodes) and gossip to that peer all of our connected peers.
this in turn means that sending is almost always involving all connected peers. which in turn also means that the timestamps on the individual pendingGossip entries would be almost identical (making the field even more so redundant)

@sbackend123
sbackend123 requested a review from acud July 16, 2026 11:13

@gacevicljubisa gacevicljubisa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work.

One idea: instead of deciding what to do based on how many peers are passed in, it might be cleaner to add a separate method like GossipPeer(addressee, peer) on discovery.Driver just for the buffered case. Make BroadcastPeers plain => send now and return an error method, and use GossipPeer as async (fire and forget) in kademlia.go:1082-1090 and flush in startGossipCoalescer worker. This allows you to drop coalesceThreshold. Also, the "buffer is full" logic, you can also move to the background worker with a wakeup channel so buffering never blocks or sends directly.

Comment thread pkg/hive/gossip_buffer.go Outdated

key := addressee.ByteString()
peerSet, ok := b.pending[key]
if !ok {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since maps leak memory by design, it would be better to:

  1. check whether the b.pending key exists
  2. merge its contents with peers if it does before writing the key to the map
  3. check the max batch size and if the entry really exceeds max batch - return early without writing to the map
  4. finally if we are within the bounds of the max batch - write to map

Comment thread pkg/hive/hive.go Outdated
if err != nil {
s.logger.Debug("coalesced gossip flush failed", "addressee", addressee, "reason", reason, "batch_size", len(peers), "error", err)
}
cancel()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ideally this should be a defer call just after it gets created.

Comment thread pkg/hive/hive.go Outdated
select {
case <-ticker.C:
for _, batch := range s.gossipBuf.takeAll() {
s.flushGossipBatch(batch.addressee, batch.peers, coalesceFlushReasonTimer)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit - this is a blocking call that makes slower peers to block other peers from getting the information. i would tend to turn this into go s.flushGossipBatch. iirc the latest go compilers make sure the values get copied correctly such that when the iterator changes batch values it doesn't change the underlying value for the goroutines already dispatched with that same variable name. but maybe also putting this into a closure won't hurt too much.

Comment thread pkg/hive/gossip_buffer.go Outdated
)

const (
defaultGossipCoalesceInterval = time.Second

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: i think this can be higher (like 5 sec)? the same for the coalesce threshold - we want to have bigger messages and less often. the timer fires every 5 seconds anyway.

Comment thread pkg/hive/gossip_buffer.go Outdated
peers: slices.Collect(maps.Values(peerSet)),
})
}
b.pending = make(map[string]map[string]swarm.Address)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clear(b.pending)

@sbackend123

Copy link
Copy Markdown
Contributor Author

Nice work.

One idea: instead of deciding what to do based on how many peers are passed in, it might be cleaner to add a separate method like GossipPeer(addressee, peer) on discovery.Driver just for the buffered case. Make BroadcastPeers plain => send now and return an error method, and use GossipPeer as async (fire and forget) in kademlia.go:1082-1090 and flush in startGossipCoalescer worker. This allows you to drop coalesceThreshold. Also, the "buffer is full" logic, you can also move to the background worker with a wakeup channel so buffering never blocks or sends directly.

Nice idea, but looks like more changes than we need (?)
@acud wdyt?

@sbackend123
sbackend123 requested a review from acud August 20, 2026 09:50
@acud

acud commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

wdyt

not sure... also having a buffered vs unbuffered kinda defeats the purpose of batching the writes together. not sure if i see the case of non-buffered broadcast as needed. the thresholds should take care of that already - when the group is big enough - it is urgent enough (and that would always be the case for a peer that connects and gets a bunch of peers via gossip). so we can use the implicit behavior in this case instead of expanding the interfaces. my 2 cents

@acud acud left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Comment thread pkg/hive/hive.go
s.metrics.BroadcastPeersPeers.Add(float64(len(peers)))

// Already-batched messages go out immediately; single-peer gossips are coalesced.
if len(peers) >= coalesceThreshold {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the addressee already has some peers which are queued for sending through the buffer - they are silently skipped here. not a big issue, can be handled later too. flagging this nevertheless.

also, you might want to gossip, but the rate limiter won't allow you to send the whole batch together because you can't get enough tokens from the bucket. this puts things as a best effort. i'm not sure we should handle all those edge cases right away but they are definitely worth documenting at least inline and perhaps as a follow up issue.

Comment thread pkg/hive/hive.go Outdated
select {
case <-ticker.C:
for _, batch := range s.gossipBuf.takeAll() {
go func(batch gossipBatch) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Untracked goroutine, this will bypass s.wg:

  • go func(batch) is not wrapped in s.wg.Go(...).
  • When the node shuts down, Service.Close() closes s.quit and calls s.wg.Wait().
  • s.wg.Wait() immediately finishes without waiting for the flushGossipBatch goroutines.
  • Inside flushGossipBatch
ctx, cancel := context.WithTimeout(context.Background(), messageTimeout) // 1 minute timeout!

The orphaned goroutines keep attempting to dial peers and open streams on the libp2p transport for up to 60 seconds after the node has shut down.

@martinconic

Copy link
Copy Markdown
Contributor

Is the following scenario a valid one?

The Scenario
At time = 0s: Single peer PeerA is announced -> buffered in gossipBuf for RemoteNode.
At time = 1s: A batch of 5 peers [P1, P2, P3, P4, P5] is broadcast -> since length is 5 or more, it bypasses the buffer and sends immediately via Stream 1.
RemoteNode receives [P1..P5] before receiving PeerA.
At time = 5s: The timer fires and sends PeerA in Stream 2.

Time 0s: [Buffer: PeerA]
Time 1s: [Immediate Broadcast: P1, P2, P3, P4, P5] ----> Sent in Stream #1
Time 5s: [Coalescer Flush: PeerA] --------------------> Sent in Stream #2

Consequences:
Older gossip arrives after newer gossip.
If P1 was also in the buffer, it gets transmitted twice (Stream 1 + Stream 2).
If RemoteNode disconnects at time = 3s, PeerA is purged on disconnect and never delivered, even though later peers were delivered.

@aloknerurkar aloknerurkar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it now makes more sense to have one bgCtx in the service which is cancelled on quit. There seems to be a lot of cases where we read ctx.Done + s.quit. This can be condensed. Maybe we can do it in a separate PR.

Comment thread pkg/hive/hive.go Outdated
select {
case <-ticker.C:
for _, batch := range s.gossipBuf.takeAll() {
go func(batch gossipBatch) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goroutines started here are not tracked using the waitgroup. If a shutdown happens in worst case these routines will wait till messageTimeout to exit and keep trying to write on p2p. Currently there is no test to Close mid-flush which is why we don't find this leak in the tests.

We should create these go routines using the waitgroup. Also maybe a test to verify everything closes correctly mid-flush.

Comment thread pkg/hive/hive.go
if !s.outLimiter.Allow(addressee.ByteString(), maxSize) {
if coalesced {
s.metrics.GossipCoalesceDropped.Add(float64(len(peers)))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just checking if its worth to add a debug log here rather than silently dropping.

@gacevicljubisa
gacevicljubisa requested a review from janos September 3, 2026 09:54

@gacevicljubisa gacevicljubisa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if i see the case of non-buffered broadcast as needed. the thresholds should take care of that already - when the group is big enough - it is urgent enough

One thing to check: on a small cluster (beekeeper, or any node right after start, since only confirmed-reachable peers count) the introduction list at kademlia.go:1155 has fewer than 5 peers, so it gets buffered and returns nil. Does the Disconnect("failed broadcasting to peer") branch below it ever run then, and what happens to the list if the peer drops before the tick?

And the reason why I suggested GossipPeer is that it lets the caller say "this one can wait" instead of hive guessing from the count. Announce/AnnounceTo use it for the single-peer gossip, BroadcastPeers stays send-now-and-return-the-error.

PR overall looks good to me. Just address the comments regarding the untracked goroutines.

@gacevicljubisa

Copy link
Copy Markdown
Member

One more thing at hive.go:199: the batch asks for all its tokens at once, and if the limiter says no the whole batch is dropped, not put back. After an addressee's burst is spent (refill is 1 token/min) nothing gets through until 30 tokens accumulate, ~30 minutes for a full batch, and every batch tried in between is lost.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants