Skip to content

Commit de6db86

Browse files
committed
beacon: shard nodes map + SO_REUSEPORT + throttle relay-not-found log
The local beacon couldn't sustain full fleet Discover load. Three bottlenecks stacked: 1. Single read goroutine on one UDP socket — kernel queue drained by one CPU. Replaced with SO_REUSEPORT and one reader per CPU; the kernel flow-hashes packets across N independent sockets. 2. Every Discover/Punch took the global s.mu write lock. With ~50k daemons rediscovering on a beacon swap that meant ~830 contended writes/sec on the read-loop's critical path. Replaced s.nodes with nodeMap: 64 shards keyed by nodeID, lock-per-shard. 3. "relay dest not found" logged synchronously per packet. When s.nodes is empty (typical for the 60s warmup after a swap) the log fan-out hit ~800 journald writes/sec, blocking the workers. Rate-limited to ≤1/s, same pattern as relayDropped. Measured on pilot-rendezvous-new under live load (~160k active nodes): recv-q peak 7.75 MB → 8 kB transient; relay-not-found log rate 826/s → 1/s (throttle target); STUN reflection correct from off-VPC clients. Linux SO_REUSEPORT lives in reuseport_linux.go; non-Linux falls back to plain ListenUDP for dev parity.
1 parent 32808dd commit de6db86

5 files changed

Lines changed: 370 additions & 86 deletions

File tree

pkg/beacon/nodes_shard.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package beacon
4+
5+
import (
6+
"net"
7+
"sync"
8+
"time"
9+
)
10+
11+
// nodeShardCount controls how the node-id space is partitioned across
12+
// independent locks. A power of two so the modulo lowers to a bitmask.
13+
// 64 was picked empirically: enough shards that 50k+ concurrent Discovers
14+
// see negligible contention on any single shard, low enough that the
15+
// per-shard map overhead stays trivial.
16+
const nodeShardCount = 64
17+
18+
// nodeShard owns one partition of the node-id space.
19+
type nodeShard struct {
20+
mu sync.RWMutex
21+
nodes map[uint32]*beaconNode
22+
}
23+
24+
// nodeMap is a fixed-size array of nodeShards keyed by nodeID modulo
25+
// nodeShardCount. Replaces the single map+RWMutex in Server that became
26+
// the contention bottleneck under fleet-scale Discover load — every
27+
// Discover/Punch packet used to acquire one global write lock; with
28+
// nodeMap the lock is per-shard so 64 cores can hot-write in parallel.
29+
type nodeMap struct {
30+
shards [nodeShardCount]*nodeShard
31+
}
32+
33+
func newNodeMap() *nodeMap {
34+
m := &nodeMap{}
35+
for i := range m.shards {
36+
m.shards[i] = &nodeShard{nodes: make(map[uint32]*beaconNode)}
37+
}
38+
return m
39+
}
40+
41+
func (m *nodeMap) shardFor(nodeID uint32) *nodeShard {
42+
return m.shards[nodeID&(nodeShardCount-1)]
43+
}
44+
45+
// Upsert refreshes or inserts a node. maxTotal is the global cap; we
46+
// approximate by capping each shard to maxTotal/nodeShardCount so we
47+
// never have to take all 64 locks. Returns the resulting (post-write)
48+
// state via the inserted/atCapacity flags.
49+
func (m *nodeMap) Upsert(nodeID uint32, addr *net.UDPAddr, now time.Time, maxTotal int) (inserted, atCapacity bool) {
50+
s := m.shardFor(nodeID)
51+
s.mu.Lock()
52+
defer s.mu.Unlock()
53+
if existing, ok := s.nodes[nodeID]; ok {
54+
existing.addr = addr
55+
existing.lastSeen = now
56+
return false, false
57+
}
58+
if maxTotal > 0 && len(s.nodes) >= maxTotal/nodeShardCount {
59+
return false, true
60+
}
61+
s.nodes[nodeID] = &beaconNode{addr: addr, lastSeen: now}
62+
return true, false
63+
}
64+
65+
// Get returns the stored node entry or nil. Cheap RLock, no allocation.
66+
func (m *nodeMap) Get(nodeID uint32) *beaconNode {
67+
s := m.shardFor(nodeID)
68+
s.mu.RLock()
69+
defer s.mu.RUnlock()
70+
return s.nodes[nodeID]
71+
}
72+
73+
// Has reports whether the node is locally registered.
74+
func (m *nodeMap) Has(nodeID uint32) bool {
75+
s := m.shardFor(nodeID)
76+
s.mu.RLock()
77+
_, ok := s.nodes[nodeID]
78+
s.mu.RUnlock()
79+
return ok
80+
}
81+
82+
// Len returns the total number of registered nodes across all shards.
83+
// Cost is O(shardCount) — fine for stats/dashboards, not the hot path.
84+
func (m *nodeMap) Len() int {
85+
total := 0
86+
for _, s := range m.shards {
87+
s.mu.RLock()
88+
total += len(s.nodes)
89+
s.mu.RUnlock()
90+
}
91+
return total
92+
}
93+
94+
// IDs returns a point-in-time snapshot of every locally registered
95+
// nodeID. Order is undefined. Used by gossip to broadcast our node
96+
// set to peer beacons.
97+
func (m *nodeMap) IDs() []uint32 {
98+
out := make([]uint32, 0, m.Len())
99+
for _, s := range m.shards {
100+
s.mu.RLock()
101+
for id := range s.nodes {
102+
out = append(out, id)
103+
}
104+
s.mu.RUnlock()
105+
}
106+
return out
107+
}
108+
109+
// ReapStale deletes every entry whose lastSeen is strictly before
110+
// threshold. Locks each shard once for the scan; safe to call from a
111+
// timer goroutine while the hot path runs.
112+
func (m *nodeMap) ReapStale(threshold time.Time) int {
113+
removed := 0
114+
for _, s := range m.shards {
115+
s.mu.Lock()
116+
for id, n := range s.nodes {
117+
if n.lastSeen.Before(threshold) {
118+
delete(s.nodes, id)
119+
removed++
120+
}
121+
}
122+
s.mu.Unlock()
123+
}
124+
return removed
125+
}

pkg/beacon/reuseport_linux.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
//go:build linux
4+
5+
package beacon
6+
7+
import (
8+
"context"
9+
"net"
10+
"syscall"
11+
)
12+
13+
// soReusePort is the Linux value for SO_REUSEPORT. The Go stdlib syscall
14+
// package doesn't export it on Linux (only Darwin), so we declare it
15+
// here to avoid taking a dependency on golang.org/x/sys/unix.
16+
const soReusePort = 0xf
17+
18+
// listenReusePort binds a UDP socket to addr with SO_REUSEPORT enabled,
19+
// which lets multiple sockets bind to the same port. The kernel
20+
// flow-hashes incoming packets across all such sockets — true parallel
21+
// receive without user-space coordination. We spawn one reader per
22+
// CPU core, each on its own socket, eliminating the single-reader
23+
// bottleneck of plain net.ListenUDP.
24+
func listenReusePort(addr string) (*net.UDPConn, error) {
25+
lc := &net.ListenConfig{
26+
Control: func(network, address string, c syscall.RawConn) error {
27+
var sockErr error
28+
err := c.Control(func(fd uintptr) {
29+
sockErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, soReusePort, 1)
30+
})
31+
if err != nil {
32+
return err
33+
}
34+
return sockErr
35+
},
36+
}
37+
pc, err := lc.ListenPacket(context.Background(), "udp", addr)
38+
if err != nil {
39+
return nil, err
40+
}
41+
return pc.(*net.UDPConn), nil
42+
}

pkg/beacon/reuseport_other.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
//go:build !linux
4+
5+
package beacon
6+
7+
import "net"
8+
9+
// listenReusePort falls back to plain net.ListenUDP on non-Linux
10+
// platforms. SO_REUSEPORT semantics vary across OSes (notably Darwin's
11+
// version doesn't flow-hash like Linux's does), and the build target
12+
// for production is Linux. On Mac dev / non-Linux CI runners we just
13+
// open a single socket and accept the lower throughput — correctness
14+
// is unchanged.
15+
func listenReusePort(addr string) (*net.UDPConn, error) {
16+
udpAddr, err := net.ResolveUDPAddr("udp", addr)
17+
if err != nil {
18+
return nil, err
19+
}
20+
return net.ListenUDP("udp", udpAddr)
21+
}

0 commit comments

Comments
 (0)