Skip to content

Commit e9a3434

Browse files
kalabukdimaclaude
andauthored
Delay before reconnecting to the worker NET-883 (#207)
## Problem The portal was burning a lot of CPU and running OOM. Root cause: when many workers refuse the connections (NET-883), each refusal closes the connection and `on_connection_closed` re-dialed **instantly**. The dial→refuse→redial loop ran at QUIC-handshake speed — `transport_libp2p_identify_errors_total` climbed ~4,100/sec — churning anon heap that jemalloc retained until OOM. ## Fix Add a flat per-peer reconnect cooldown between a connection close and its redial. Default 30s, override via `RECONNECT_COOLDOWN_SEC`. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 496602b commit e9a3434

3 files changed

Lines changed: 43 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/transport/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ rand = { version = "0.9.2", optional = true }
2727
serde = { version = "1", features = ["derive"] }
2828
thiserror = "1"
2929
tokio = { version = "1", features = ["fs", "macros", "sync"] }
30-
tokio-util = "0.7"
30+
tokio-util = { version = "0.7", features = ["time"] }
3131
tokio-stream = "0.1.17"
3232

3333
sqd-contract-client = { path = "../contract-client" }

crates/transport/src/behaviour/base.rs

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use libp2p::{
2626
};
2727
use libp2p_swarm_derive::NetworkBehaviour;
2828
use serde::{Deserialize, Serialize};
29+
use tokio_util::time::DelayQueue;
2930

3031
use sqd_contract_client::{Client as ContractClient, NetworkNodes};
3132

@@ -80,6 +81,9 @@ pub struct BaseConfig {
8081
pub addr_cache_size: NonZeroUsize,
8182
/// Maximum number of concurrent Kademlia lookups for worker discovery (default: 20)
8283
pub max_concurrent_lookups: usize,
84+
/// Cooldown before re-dialing a worker after its connection closes (default: 30 sec).
85+
/// Prevents a tight dial→refuse→redial loop against workers that reject this peer.
86+
pub reconnect_cooldown: Duration,
8387
}
8488

8589
impl BaseConfig {
@@ -94,6 +98,7 @@ impl BaseConfig {
9498
let addr_cache_size = NonZeroUsize::new(parse_env_var("ADDR_CACHE_SIZE", 1024))
9599
.expect("addr_cache_size should be > 0");
96100
let max_concurrent_lookups = parse_env_var("MAX_CONCURRENT_LOOKUPS", 20);
101+
let reconnect_cooldown = Duration::from_secs(parse_env_var("RECONNECT_COOLDOWN_SEC", 30));
97102
Self {
98103
onchain_update_interval,
99104
autonat_timeout,
@@ -103,6 +108,7 @@ impl BaseConfig {
103108
max_pubsub_msg_size,
104109
addr_cache_size,
105110
max_concurrent_lookups,
111+
reconnect_cooldown,
106112
}
107113
}
108114
}
@@ -121,6 +127,12 @@ pub struct BaseBehaviour {
121127
maintain_worker_connections: bool,
122128
pending_lookups: VecDeque<PeerId>,
123129
max_concurrent_lookups: usize,
130+
/// Flat cooldown applied before re-dialing a worker whose connection just closed.
131+
reconnect_cooldown: Duration,
132+
/// Workers awaiting a cooldown before reconnection.
133+
reconnect_queue: DelayQueue<PeerId>,
134+
/// Peers currently present in `reconnect_queue`, to avoid scheduling duplicates.
135+
reconnect_scheduled: HashSet<PeerId>,
124136
}
125137

126138
#[allow(dead_code)]
@@ -195,6 +207,9 @@ impl BaseBehaviour {
195207
maintain_worker_connections: false,
196208
pending_lookups: Default::default(),
197209
max_concurrent_lookups: config.max_concurrent_lookups,
210+
reconnect_cooldown: config.reconnect_cooldown,
211+
reconnect_queue: DelayQueue::new(),
212+
reconnect_scheduled: HashSet::new(),
198213
}
199214
}
200215

@@ -300,9 +315,17 @@ impl BehaviourWrapper for BaseBehaviour {
300315
}
301316
}
302317

303-
fn poll(&mut self, _cx: &mut Context<'_>) -> Poll<impl IntoIterator<Item = TToSwarm<Self>>> {
304-
if let Some(ev) = self.pending_events.pop_front() {
305-
return Poll::Ready(Some(ev));
318+
fn poll(&mut self, cx: &mut Context<'_>) -> Poll<impl IntoIterator<Item = TToSwarm<Self>>> {
319+
// Drain expired reconnects first, unconditionally: `poll_expired` is what registers
320+
// the timer waker, so it must run even when there's a pending event to emit. Skipping
321+
// it behind the early return below would let a cooldown entry expire without a
322+
// registered waker, deferring the redial until some unrelated event wakes the task.
323+
while let Poll::Ready(Some(expired)) = self.reconnect_queue.poll_expired(cx) {
324+
let peer_id = expired.into_inner();
325+
self.reconnect_scheduled.remove(&peer_id);
326+
if !self.outbound_conn_exists(&peer_id) && self.registered_workers.contains(&peer_id) {
327+
self.pending_lookups.push_back(peer_id);
328+
}
306329
}
307330

308331
while self.ongoing_lookups.len() < self.max_concurrent_lookups {
@@ -316,6 +339,10 @@ impl BehaviourWrapper for BaseBehaviour {
316339
}
317340
}
318341

342+
if let Some(ev) = self.pending_events.pop_front() {
343+
return Poll::Ready(Some(ev));
344+
}
345+
319346
Poll::Pending
320347
}
321348
}
@@ -348,9 +375,15 @@ impl BaseBehaviour {
348375
if self.maintain_worker_connections
349376
&& !self.outbound_conn_exists(&peer_id)
350377
&& self.registered_workers.contains(&peer_id)
378+
// Skip if a reconnect is already pending (avoids a tight redial loop and
379+
// unbounded duplicate entries for workers that keep refusing this peer).
380+
&& self.reconnect_scheduled.insert(peer_id)
351381
{
352-
log::debug!("Worker {peer_id} disconnected, scheduling reconnection");
353-
self.pending_lookups.push_back(peer_id);
382+
log::debug!(
383+
"Worker {peer_id} disconnected, scheduling reconnection in {:?}",
384+
self.reconnect_cooldown
385+
);
386+
self.reconnect_queue.insert(peer_id, self.reconnect_cooldown);
354387
}
355388
None
356389
}
@@ -526,7 +559,9 @@ impl BaseBehaviour {
526559

527560
if self.maintain_worker_connections {
528561
for peer_id in &self.registered_workers {
529-
if !self.outbound_conn_exists(peer_id) {
562+
if !self.outbound_conn_exists(peer_id)
563+
&& !self.reconnect_scheduled.contains(peer_id)
564+
{
530565
self.pending_lookups.push_back(*peer_id);
531566
}
532567
}

0 commit comments

Comments
 (0)