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. |
Yes, this scenario is possible, but in my opinion it would only be a problem in a very sparse or unstable topology. |
|
|
||
| func (s *Service) Close() error { | ||
| close(s.quit) | ||
| s.bgCancel() |
There was a problem hiding this comment.
So idea was to not have both. Right now it seems like we are duplicating the functionality. In some cases we are waiting on s.quit and in some cases we are waiting on bgCtx.Done. This has become more confusing than it was.
If we have bgCtx we can remove s.quit. In broadcastNow function when we are using the caller context we need to merge the cancel functions of both bgCtx and the calling context. That way we obey both cancels. This way we can remove s.quit. But this is not so straightforward, which is why I suggested doing it in a separate PR.
| } | ||
|
|
||
| // If broadcasting limit is exceeded, return early | ||
| if !s.outLimiter.Allow(addressee.ByteString(), maxSize) { |
There was a problem hiding this comment.
Because takeAll() already removed all buffered peers from b.pending, if s.outLimiter.Allow(..., 30) returns false (when available tokens < 30), broadcastNow returns nil and all 30 peers are permanently lost.
Since limitRate is time.Minute (1 token/minute refill), if a bucket is depleted, a node will drop all 30-peer batches for up to 30 minutes.
We should re-enqueue the rejected peers back into the buffer so they can be sent on subsequent ticks or when tokens become available
There was a problem hiding this comment.
Yes, we decided not to cover all corner cases now
#5520 (comment)
I can just document it as Elad suggested
| // coalesceThreshold peers are buffered and flushed asynchronously; errors | ||
| // during deferred dispatch are logged but not returned to the caller. | ||
| // Calls with coalesceThreshold or more peers are sent immediately. | ||
| func (s *Service) BroadcastPeers(ctx context.Context, addressee swarm.Address, peers ...swarm.Address) error { |
There was a problem hiding this comment.
If a caller invokes BroadcastPeers with a context that is already canceled or has a short deadline, the check is bypassed and the peer is buffered anyway, returning nil. We should validate ctx.Err() before staging into memory
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