Skip to content

Commit ce4e02d

Browse files
committed
Cut send-message latency 50-170x: hostname cache + fetchMembers backoff
Symptom: pilotctl send-message took 10-30 seconds per call against a warm tunnel — actual relay RTT is ~170ms. Per-step tracing showed: TRACE parseAddrOrHostname 6879ms ← shared regConn mutex contention TRACE dataexchange.Dial ~200ms TRACE client.Recv ~200ms The PolicyRunner's reconcileMembership ticker fires every 5s and calls ListNodes on the regConn. For a network with thousands of members (network 9 = ~10k+ peers in this fleet), the response exceeds the client read window and EOFs mid-stream. The reconnect cycle pounds the regConn lock continuously, so any other call (resolve_hostname, lookup, etc.) that needs regConn blocks until the next failure cycle completes. Two complementary fixes: 1. Daemon-side hostname cache (pkg/daemon/ipc.go handleResolveHostname): 60s positive cache keyed on the literal hostname string. Repeated send-message calls to the same agent serve from cache without touching regConn at all. Negative results are NOT cached — transient registry errors don't poison lookups. 2. Exponential backoff on fetchMembers (pkg/daemon/policy_runner.go): On EOF/error, skip the next ticks for 5s → 10s → 20s → 40s → 80s, capped at 5 minutes. Reset to 5s on first success. Logs only on power-of-two failure transitions to avoid spam. Stops the regConn lock from being held continuously by a doomed call. Bonus: pilotctl send-message gains an opt-in PILOTCTL_TRACE_TIME=1 env flag for latency debugging — prints per-step timings to stderr. Measured: 6879ms hostname → 0.2ms cached. Cold call 1.6s → warm call 175ms. Total send-message reduced from 10-30s to ~600ms cold / ~170ms warm.
1 parent 26824f4 commit ce4e02d

4 files changed

Lines changed: 118 additions & 1 deletion

File tree

cmd/pilotctl/main.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2848,10 +2848,23 @@ func cmdSendMessage(args []string) {
28482848
fatalCode("invalid_argument", "usage: pilotctl send-message <address|hostname> --data <text> [--type text|json|binary]")
28492849
}
28502850

2851+
// Optional latency tracing: set PILOTCTL_TRACE_TIME=1 to dump per-step
2852+
// timings to stderr. Useful when debugging slow agents — distinguishes
2853+
// hostname-resolve, dial, and recv contributions.
2854+
traceTime := os.Getenv("PILOTCTL_TRACE_TIME") != ""
2855+
t0 := time.Now()
2856+
tracef := func(label string) {
2857+
if traceTime {
2858+
fmt.Fprintf(os.Stderr, "TRACE %-22s %12.3fms\n", label, float64(time.Since(t0).Microseconds())/1000.0)
2859+
}
2860+
}
2861+
28512862
d := connectDriver()
2863+
tracef("connectDriver")
28522864
defer d.Close()
28532865

28542866
target, err := parseAddrOrHostname(d, pos[0])
2867+
tracef("parseAddrOrHostname")
28552868
if err != nil {
28562869
fatalCode("not_found", "%v", err)
28572870
}
@@ -2863,6 +2876,7 @@ func cmdSendMessage(args []string) {
28632876
msgType := flagString(flags, "type", "text")
28642877

28652878
client, err := dataexchange.Dial(d, target)
2879+
tracef("dataexchange.Dial")
28662880
if err != nil {
28672881
hint := classifyDaemonError(err)
28682882
if hint == "" {
@@ -2883,12 +2897,14 @@ func cmdSendMessage(args []string) {
28832897
default:
28842898
fatalCode("invalid_argument", "unknown type %q (use text, json, or binary)", msgType)
28852899
}
2900+
tracef("client.Send")
28862901
if err != nil {
28872902
fatalCode("connection_failed", "send: %v", err)
28882903
}
28892904

28902905
// Read ACK
28912906
ack, err := client.Recv()
2907+
tracef("client.Recv")
28922908
if err != nil {
28932909
slog.Debug("send-message ACK read failed", "err", err)
28942910
}
@@ -2902,6 +2918,7 @@ func cmdSendMessage(args []string) {
29022918
result["ack"] = string(ack.Payload)
29032919
}
29042920
outputOK(result)
2921+
tracef("outputOK")
29052922
}
29062923

29072924
// ===================== TASK SUBCOMMANDS =====================

pkg/daemon/daemon.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,18 @@ type resolveEntry struct {
198198
cachedAt time.Time
199199
}
200200

201+
// hostnameCacheEntry caches a resolve_hostname response so repeated
202+
// send-message calls to the same peer don't pound the registry.
203+
type hostnameCacheEntry struct {
204+
resp map[string]interface{}
205+
cachedAt time.Time
206+
}
207+
208+
// hostnameCacheTTL is how long a cached hostname resolution stays
209+
// valid. Short enough that legitimate hostname rotations propagate
210+
// within a minute; long enough to absorb rapid-fire CLI invocations.
211+
const hostnameCacheTTL = 60 * time.Second
212+
201213
type Daemon struct {
202214
config Config
203215
addrMu sync.RWMutex // protects nodeID, addr, publicEndpoint (H6 fix)
@@ -234,6 +246,14 @@ type Daemon struct {
234246
resolveCacheMu sync.RWMutex
235247
resolveCache map[uint32]*resolveEntry
236248

249+
// Hostname cache: hostname string -> cached resolve_hostname response.
250+
// Avoids hitting the registry for every send-message to the same peer
251+
// when the regConn is contended (policy fetchMembers loops, etc.).
252+
// 60s TTL — hostnames do change (rotation, reassignment) but rarely
253+
// fast enough for sub-minute staleness to matter.
254+
hostnameCacheMu sync.RWMutex
255+
hostnameCache map[string]*hostnameCacheEntry
256+
237257
// SYN rate limiter (token bucket)
238258
synMu sync.Mutex
239259
synTokens int
@@ -329,6 +349,7 @@ func New(cfg Config) *Daemon {
329349
perSrcSYN: make(map[uint32]*srcSYNBucket),
330350
epCache: make(map[uint32]*endpointEntry),
331351
resolveCache: make(map[uint32]*resolveEntry),
352+
hostnameCache: make(map[string]*hostnameCacheEntry),
332353
netPolicies: make(map[uint16][]uint16),
333354
managed: make(map[uint16]*ManagedEngine),
334355
policyRunners: make(map[uint16]*PolicyRunner),

pkg/daemon/ipc.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,12 +820,45 @@ func (s *IPCServer) handleResolveHostname(conn *ipcConn, payload []byte) {
820820
return
821821
}
822822

823+
// Try the daemon's hostname cache first. The regConn mutex is shared
824+
// with the policy_runner's fetchMembers loop; on networks that trip
825+
// the list_nodes EOF cycle the lookup can block for seconds. Most
826+
// CLI invocations resolve the same hostname repeatedly (a chained
827+
// agent script doing dozens of `pilotctl send-message agent X`
828+
// calls), so a 60s positive cache turns those calls into IPC-only
829+
// round trips.
830+
now := time.Now()
831+
s.daemon.hostnameCacheMu.RLock()
832+
if e, ok := s.daemon.hostnameCache[hostname]; ok && now.Sub(e.cachedAt) < hostnameCacheTTL {
833+
cached := e.resp
834+
s.daemon.hostnameCacheMu.RUnlock()
835+
if data, err := json.Marshal(cached); err == nil {
836+
resp := make([]byte, 1+len(data))
837+
resp[0] = CmdResolveHostnameOK
838+
copy(resp[1:], data)
839+
if err := conn.ipcWrite(resp); err != nil {
840+
slog.Debug("IPC resolve_hostname (cached) reply failed", "err", err)
841+
}
842+
return
843+
}
844+
// fall through on marshal error (shouldn't happen)
845+
} else {
846+
s.daemon.hostnameCacheMu.RUnlock()
847+
}
848+
823849
result, err := s.daemon.regConn.ResolveHostname(hostname)
824850
if err != nil {
825851
s.sendError(conn, fmt.Sprintf("resolve_hostname: %v", err))
826852
return
827853
}
828854

855+
// Cache the successful response. Only cache positive results so
856+
// transient registry hiccups don't poison the cache with a bogus
857+
// "not found".
858+
s.daemon.hostnameCacheMu.Lock()
859+
s.daemon.hostnameCache[hostname] = &hostnameCacheEntry{resp: result, cachedAt: now}
860+
s.daemon.hostnameCacheMu.Unlock()
861+
829862
data, err := json.Marshal(result)
830863
if err != nil {
831864
s.sendError(conn, fmt.Sprintf("resolve_hostname marshal: %v", err))

pkg/daemon/policy_runner.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ type PolicyRunner struct {
4747
stopCh chan struct{}
4848
done chan struct{}
4949
path string // persistence path (~/.pilot/policy_<netID>.json)
50+
51+
// fetchMembers backoff. Some networks are too large for the registry's
52+
// list_nodes response to fit a single read window — fetchMembers EOFs
53+
// every 5s tick and pounds the regConn mutex, which adds 5+ seconds
54+
// of latency to ANY other call (resolve_hostname, lookup, etc) that
55+
// shares regConn. Track consecutive failures and skip ticks until
56+
// the next backoff deadline.
57+
fetchFailMu sync.Mutex
58+
fetchFailures int // consecutive failure count
59+
fetchSkipUntil time.Time // skip ticks before this time
5060
}
5161

5262
// policySnapshot is the JSON format persisted to disk.
@@ -1035,11 +1045,47 @@ func (pr *PolicyRunner) fetchMembers() ([]uint32, error) {
10351045
// fetchMembersWithTags returns member IDs and their admin-assigned tags.
10361046
// Also updates the daemon's local member tags cache for the local node.
10371047
func (pr *PolicyRunner) fetchMembersWithTags() []fetchedMember {
1048+
// Skip if recent failures put us in backoff. Prevents the 5s tick
1049+
// from re-EOF'ing the regConn and starving co-tenant calls
1050+
// (resolve_hostname, lookup) of the shared mutex.
1051+
pr.fetchFailMu.Lock()
1052+
if !pr.fetchSkipUntil.IsZero() && time.Now().Before(pr.fetchSkipUntil) {
1053+
pr.fetchFailMu.Unlock()
1054+
return nil
1055+
}
1056+
pr.fetchFailMu.Unlock()
1057+
10381058
resp, err := pr.daemon.regConn.ListNodes(pr.netID, pr.daemon.config.AdminToken)
10391059
if err != nil {
1040-
slog.Warn("policy: fetchMembers failed", "network_id", pr.netID, "err", err)
1060+
// Exponential backoff: 5s → 10s → 20s → 40s → 80s, capped at 5min.
1061+
// Logs only on transitions (1st, 4th, 8th, 16th failure) so the
1062+
// user sees the issue without log spam.
1063+
pr.fetchFailMu.Lock()
1064+
pr.fetchFailures++
1065+
fails := pr.fetchFailures
1066+
backoff := time.Duration(1<<min(fails, 6)) * 5 * time.Second
1067+
if backoff > 5*time.Minute {
1068+
backoff = 5 * time.Minute
1069+
}
1070+
pr.fetchSkipUntil = time.Now().Add(backoff)
1071+
pr.fetchFailMu.Unlock()
1072+
// Log on power-of-two transitions to avoid spam.
1073+
if fails == 1 || fails == 4 || fails == 8 || fails == 16 || fails%32 == 0 {
1074+
slog.Warn("policy: fetchMembers failed",
1075+
"network_id", pr.netID, "err", err,
1076+
"consecutive_failures", fails, "next_attempt_in", backoff.String())
1077+
}
10411078
return nil
10421079
}
1080+
// Reset failure count on success.
1081+
pr.fetchFailMu.Lock()
1082+
if pr.fetchFailures > 0 {
1083+
slog.Info("policy: fetchMembers recovered", "network_id", pr.netID,
1084+
"after_failures", pr.fetchFailures)
1085+
pr.fetchFailures = 0
1086+
pr.fetchSkipUntil = time.Time{}
1087+
}
1088+
pr.fetchFailMu.Unlock()
10431089

10441090
nodesRaw, ok := resp["nodes"].([]interface{})
10451091
if !ok {

0 commit comments

Comments
 (0)