discovery: switch to bytes based rate limiting for outbound msgs

In this commit, we revamp the old message based rate limiting. First, we
move to meter by bytes/s instead of messages/s. The old logic had an
error in that it limited groups of message replies, instead of each
message. With this new approach, we'll use the newly added
SerializedSize method to implement fine grained bandwidth metering.

We need to pick two values, the burst rate, and the msg bytes rate. The
burst rate is the max amt that can be sent in a given period of time. We
need to set this above 65 KB, or the max msg limit, otherwise no
messages can be sent. The bucket starts with this many tokens (bytes).
As those are depleted, the amount of tokens is refilled at the msg
bytes rate.

As conservative values, we've chosen 200 KB as the burst rate, and 100
KB/s as the limit.
This commit is contained in:
Olaoluwa Osuntokun committed 2025-03-24 19:21:45 -07:00
1 parent 67d2eac437
commit 05702d48b2
4 files changed
+380 -268

No files matched your search

+152 -12
View File
@@ -1,6 +1,7 @@
package discovery
import (
"context"
"errors"
"sync"
"sync/atomic"
@@ -11,6 +12,7 @@ import (
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/ticker"
"golang.org/x/time/rate"
)
const (
@@ -25,6 +27,21 @@ const (
// filterSemaSize is the capacity of gossipFilterSema.
filterSemaSize = 5
// DefaultMsgBytesBurst is the allotted burst in bytes we'll permit.
// This is the most that can be sent in a given go. Requests beyond
// this, will block indefinitely. Once tokens (bytes are depleted),
// they'll be refilled at the DefaultMsgBytesPerSecond rate.
DefaultMsgBytesBurst = 2 * 100 * 1_024
// DefaultMsgBytesPerSecond is the max bytes/s we'll permit for outgoing
// messages. Once tokens (bytes) have been taken from the bucket,
// they'll be refilled at this rate.
DefaultMsgBytesPerSecond = 100 * 1_024
// assumedMsgSize is the assumed size of a message if we can't compute
// its serialized size. This comes out to 1 KB.
assumedMsgSize = 1_024
)
var (
@@ -110,6 +127,15 @@ type SyncManagerCfg struct {
// updates for a channel and returns true if the channel should be
// considered a zombie based on these timestamps.
IsStillZombieChannel func(time.Time, time.Time) bool
// AllotedMsgBytesPerSecond is the allotted bandwidth rate, expressed in
// bytes/second that the gossip manager can consume. Once we exceed this
// rate, message sending will block until we're below the rate.
AllotedMsgBytesPerSecond uint64
// AllotedMsgBytesBurst is the amount of burst bytes we'll permit, if
// we've exceeded the hard upper limit.
AllotedMsgBytesBurst uint64
}
// SyncManager is a subsystem of the gossiper that manages the gossip syncers
@@ -168,6 +194,12 @@ type SyncManager struct {
// queries.
gossipFilterSema chan struct{}
// rateLimiter dictates the frequency with which we will reply to gossip
// queries from a peer. This is used to delay responses to peers to
// prevent DOS vulnerabilities if they are spamming with an unreasonable
// number of queries.
rateLimiter *rate.Limiter
wg sync.WaitGroup
quit chan struct{}
}
@@ -180,8 +212,25 @@ func newSyncManager(cfg *SyncManagerCfg) *SyncManager {
filterSema <- struct{}{}
}
bytesPerSecond := cfg.AllotedMsgBytesPerSecond
if bytesPerSecond == 0 {
bytesPerSecond = DefaultMsgBytesPerSecond
}
bytesBurst := cfg.AllotedMsgBytesBurst
if bytesBurst == 0 {
bytesBurst = DefaultMsgBytesBurst
}
// We'll use this rate limiter to limit our total outbound bandwidth for
// gossip queries peers.
rateLimiter := rate.NewLimiter(
rate.Limit(bytesPerSecond), int(bytesBurst),
)
return &SyncManager{
cfg: *cfg,
rateLimiter: rateLimiter,
newSyncers: make(chan *newSyncer),
staleSyncers: make(chan *staleSyncer),
activeSyncers: make(
@@ -494,6 +543,95 @@ func (m *SyncManager) isPinnedSyncer(s *GossipSyncer) bool {
return isPinnedSyncer
}
// deriveRateLimitReservation will take the current message and derive a
// reservation that can be used to wait on the rate limiter.
func (m *SyncManager) deriveRateLimitReservation(msg lnwire.Message,
) (*rate.Reservation, error) {
var (
msgSize uint32
err error
)
// Figure out the serialized size of the message. If we can't easily
// compute it, then we'll used the assumed msg size.
if sMsg, ok := msg.(lnwire.SizeableMessage); ok {
msgSize, err = sMsg.SerializedSize()
if err != nil {
return nil, err
}
} else {
log.Warnf("Unable to compute serialized size of %T", msg)
msgSize = assumedMsgSize
}
return m.rateLimiter.ReserveN(time.Now(), int(msgSize)), nil
}
// waitMsgDelay takes a delay, and waits until it has finished.
func (m *SyncManager) waitMsgDelay(ctx context.Context, peerPub [33]byte,
limitReservation *rate.Reservation) error {
// If we've already replied a handful of times, we will start to delay
// responses back to the remote peer. This can help prevent DOS attacks
// where the remote peer spams us endlessly.
//
// We skip checking for reservation.OK() here, as during config
// validation, we ensure that the burst is enough for a single message
// to be sent.
delay := limitReservation.Delay()
if delay > 0 {
log.Infof("GossipSyncer(%x): rate limiting gossip replies, "+
"responding in %s", peerPub, delay)
select {
case <-time.After(delay):
case <-ctx.Done():
limitReservation.Cancel()
return ErrGossipSyncerExiting
case <-m.quit:
limitReservation.Cancel()
return ErrGossipSyncerExiting
}
}
return nil
}
// maybeRateLimitMsg takes a message, and may wait a period of time to rate
// limit the msg.
func (m *SyncManager) maybeRateLimitMsg(ctx context.Context, peerPub [33]byte,
msg lnwire.Message) error {
delay, err := m.deriveRateLimitReservation(msg)
if err != nil {
return nil
}
return m.waitMsgDelay(ctx, peerPub, delay)
}
// sendMessages sends a set of messages to the remote peer.
func (m *SyncManager) sendMessages(ctx context.Context, sync bool,
peer lnpeer.Peer, nodeID route.Vertex, msgs ...lnwire.Message) error {
for _, msg := range msgs {
if err := m.maybeRateLimitMsg(ctx, nodeID, msg); err != nil {
return err
}
if err := peer.SendMessageLazy(sync, msg); err != nil {
return err
}
}
return nil
}
// createGossipSyncer creates the GossipSyncer for a newly connected peer.
func (m *SyncManager) createGossipSyncer(peer lnpeer.Peer) *GossipSyncer {
nodeID := route.Vertex(peer.PubKey())
@@ -507,20 +645,22 @@ func (m *SyncManager) createGossipSyncer(peer lnpeer.Peer) *GossipSyncer {
encodingType: encoding,
chunkSize: encodingTypeToChunkSize[encoding],
batchSize: requestBatchSize,
sendToPeer: func(msgs ...lnwire.Message) error {
return peer.SendMessageLazy(false, msgs...)
sendToPeer: func(ctx context.Context,
msgs ...lnwire.Message) error {
return m.sendMessages(ctx, false, peer, nodeID, msgs...)
},
sendToPeerSync: func(msgs ...lnwire.Message) error {
return peer.SendMessageLazy(true, msgs...)
sendToPeerSync: func(ctx context.Context,
msgs ...lnwire.Message) error {
return m.sendMessages(ctx, true, peer, nodeID, msgs...)
},
ignoreHistoricalFilters: m.cfg.IgnoreHistoricalFilters,
maxUndelayedQueryReplies: DefaultMaxUndelayedQueryReplies,
delayedQueryReplyInterval: DefaultDelayedQueryReplyInterval,
bestHeight: m.cfg.BestHeight,
markGraphSynced: m.markGraphSynced,
maxQueryChanRangeReplies: maxQueryChanRangeReplies,
noTimestampQueryOption: m.cfg.NoTimestampQueries,
isStillZombieChannel: m.cfg.IsStillZombieChannel,
ignoreHistoricalFilters: m.cfg.IgnoreHistoricalFilters,
bestHeight: m.cfg.BestHeight,
markGraphSynced: m.markGraphSynced,
maxQueryChanRangeReplies: maxQueryChanRangeReplies,
noTimestampQueryOption: m.cfg.NoTimestampQueries,
isStillZombieChannel: m.cfg.IsStillZombieChannel,
}, m.gossipFilterSema)
// Gossip syncers are initialized by default in a PassiveSync type