feat: gossip improvements - #5520
Conversation
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>
| type pendingGossip struct { | ||
| addressee swarm.Address | ||
| peers map[string]swarm.Address // peer bytestring -> address (set semantics) | ||
| deadline time.Time |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
|
|
||
| key := addressee.ByteString() | ||
| peerSet, ok := b.pending[key] | ||
| if !ok { |
There was a problem hiding this comment.
since maps leak memory by design, it would be better to:
- check whether the b.pending key exists
- merge its contents with
peersif it does before writing the key to the map - check the max batch size and if the entry really exceeds max batch - return early without writing to the map
- finally if we are within the bounds of the max batch - write to map
| if err != nil { | ||
| s.logger.Debug("coalesced gossip flush failed", "addressee", addressee, "reason", reason, "batch_size", len(peers), "error", err) | ||
| } | ||
| cancel() |
There was a problem hiding this comment.
ideally this should be a defer call just after it gets created.
| select { | ||
| case <-ticker.C: | ||
| for _, batch := range s.gossipBuf.takeAll() { | ||
| s.flushGossipBatch(batch.addressee, batch.peers, coalesceFlushReasonTimer) |
There was a problem hiding this comment.
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.
| ) | ||
|
|
||
| const ( | ||
| defaultGossipCoalesceInterval = time.Second |
There was a problem hiding this comment.
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.
| peers: slices.Collect(maps.Values(peerSet)), | ||
| }) | ||
| } | ||
| b.pending = make(map[string]map[string]swarm.Address) |
Nice idea, but looks like more changes than we need (?) |
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 |
| s.metrics.BroadcastPeersPeers.Add(float64(len(peers))) | ||
|
|
||
| // Already-batched messages go out immediately; single-peer gossips are coalesced. | ||
| if len(peers) >= coalesceThreshold { |
There was a problem hiding this comment.
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.
| select { | ||
| case <-ticker.C: | ||
| for _, batch := range s.gossipBuf.takeAll() { | ||
| go func(batch gossipBatch) { |
There was a problem hiding this comment.
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.
|
Is the following scenario a valid one? The Scenario Consequences: |
aloknerurkar
left a comment
There was a problem hiding this comment.
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.
| select { | ||
| case <-ticker.C: | ||
| for _, batch := range s.gossipBuf.takeAll() { | ||
| go func(batch gossipBatch) { |
There was a problem hiding this comment.
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.
| if !s.outLimiter.Allow(addressee.ByteString(), maxSize) { | ||
| if coalesced { | ||
| s.metrics.GossipCoalesceDropped.Add(float64(len(peers))) | ||
| } |
There was a problem hiding this comment.
Just checking if its worth to add a debug log here rather than silently dropping.
gacevicljubisa
left a comment
There was a problem hiding this comment.
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.
|
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. |
Checklist
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