3030from urllib .parse import urlparse
3131
3232from ccbt .config .config import get_config
33+ from ccbt .session .peer_discovery_telemetry import observe_udp_tracker_pending_window
3334
3435if TYPE_CHECKING :
3536 from ccbt .models import PeerInfo
@@ -166,6 +167,11 @@ def __init__(self, peer_id: Optional[bytes] = None, test_mode: bool = False):
166167 self ._timeout_warning_host_state : dict [str , tuple [float , int ]] = {}
167168 self ._tracker_response_timeout_alpha : float = 0.25
168169 self ._tracker_response_timeout_ema : dict [str , float ] = {}
170+ self ._tracker_timeout_floor_scale : dict [str , float ] = {}
171+ self ._pending_request_host_by_tid : dict [int , str ] = {}
172+ self ._pending_request_soft_cap_per_host : int = 24
173+ self ._udp_wait_pacing_load_ratio : float = 0.5
174+ self ._last_udp_pending_gauge_monotonic : float = 0.0
169175
170176 # Background tasks
171177 self ._cleanup_task : Optional [asyncio .Task ] = None
@@ -195,6 +201,7 @@ def __init__(self, peer_id: Optional[bytes] = None, test_mode: bool = False):
195201 self ._xet_chunk_registry : dict [tuple [bytes , Optional [str ]], list [PeerInfo ]] = {}
196202
197203 self .logger = logging .getLogger (__name__ )
204+ self ._refresh_udp_pending_settings_from_config ()
198205 if not test_mode and not _udp_singleton_construct_in_progress ():
199206 msg = (
200207 "AsyncUDPTrackerClient must be obtained via get_udp_tracker_client() "
@@ -203,6 +210,29 @@ def __init__(self, peer_id: Optional[bytes] = None, test_mode: bool = False):
203210 )
204211 raise RuntimeError (msg )
205212
213+ def _refresh_udp_pending_settings_from_config (self ) -> None :
214+ """Apply discovery.* limits for the process-wide UDP tracker singleton."""
215+ disc = getattr (self .config , "discovery" , None )
216+ if disc is None :
217+ return
218+ with contextlib .suppress (Exception ):
219+ self ._pending_request_soft_cap_per_host = int (
220+ getattr (disc , "tracker_udp_pending_soft_cap_per_host" , 24 )
221+ )
222+ self ._max_pending_requests = int (
223+ getattr (disc , "tracker_udp_max_pending_requests" , 128 )
224+ )
225+ self ._udp_wait_pacing_load_ratio = float (
226+ getattr (disc , "tracker_udp_wait_pacing_load_ratio" , 0.5 )
227+ )
228+
229+ def _maybe_emit_udp_pending_gauge (self ) -> None :
230+ now = time .monotonic ()
231+ if now - self ._last_udp_pending_gauge_monotonic < 0.25 :
232+ return
233+ self ._last_udp_pending_gauge_monotonic = now
234+ observe_udp_tracker_pending_window (len (self .pending_requests ))
235+
206236 @property
207237 def socket_ready (self ) -> bool :
208238 """Check if socket is ready.
@@ -380,10 +410,16 @@ def _get_adaptive_wait_timeout(
380410 queue_pressure = pending_count / effective_cap if effective_cap > 0 else 0.0
381411 queue_pressure = max (0.0 , min (1.0 , queue_pressure ))
382412
383- # When there is queue pressure, reduce per-request timeout to avoid long stalls .
384- queue_scale = 1.0 - ( 0.45 * queue_pressure )
385-
413+ # Queue pressure scaling with congestion floor + hysteresis .
414+ # Slightly steeper than legacy 0.45 to shorten waits under multiplex load.
415+ queue_scale = 1.0 - ( 0.55 * queue_pressure )
386416 host_key = self ._get_tracker_host (tracker_host )
417+ previous_floor = float (self ._tracker_timeout_floor_scale .get (host_key , 0.65 ))
418+ target_floor = 0.65 if queue_pressure < 0.7 else 0.8
419+ floor_scale = (0.85 * previous_floor ) + (0.15 * target_floor )
420+ self ._tracker_timeout_floor_scale [host_key ] = floor_scale
421+ queue_scale = max (floor_scale , queue_scale )
422+
387423 host_ema = self ._tracker_response_timeout_ema .get (host_key )
388424 host_scale = 1.0
389425 if host_ema is not None and host_ema > 0 :
@@ -636,6 +672,7 @@ async def start(self) -> None:
636672 CRITICAL: Socket must be initialized during daemon startup via start_udp_tracker_client().
637673 Socket recreation is not supported as it breaks session logic.
638674 """
675+ self ._refresh_udp_pending_settings_from_config ()
639676 # Note: Assert socket should never be recreated during runtime
640677 # If socket is already initialized and healthy, return immediately
641678 # Socket recreation breaks session logic and causes WinError 10022 on Windows
@@ -2446,6 +2483,7 @@ def _prune_stale_pending_requests(
24462483 future = self .pending_requests .pop (transaction_id , None )
24472484 self ._pending_request_timestamps .pop (transaction_id , None )
24482485 self .pending_immediate_callbacks .pop (transaction_id , None )
2486+ self ._pending_request_host_by_tid .pop (transaction_id , None )
24492487 if future is not None and not future .done ():
24502488 future .cancel ()
24512489 self ._mark_stale_transaction (transaction_id , now = now )
@@ -2468,6 +2506,7 @@ def _prune_stale_pending_requests(
24682506 future = self .pending_requests .pop (transaction_id , None )
24692507 self ._pending_request_timestamps .pop (transaction_id , None )
24702508 self .pending_immediate_callbacks .pop (transaction_id , None )
2509+ self ._pending_request_host_by_tid .pop (transaction_id , None )
24712510 if future is not None and not future .done ():
24722511 future .cancel ()
24732512 self ._mark_stale_transaction (transaction_id , now = now )
@@ -2493,6 +2532,29 @@ async def _wait_for_response(
24932532 future = asyncio .Future ()
24942533 now = time .time ()
24952534 start_wait = now
2535+ host_pending = sum (
2536+ 1 for h in self ._pending_request_host_by_tid .values () if h == host
2537+ )
2538+ if host_pending >= self ._pending_request_soft_cap_per_host :
2539+ self .logger .debug (
2540+ "Tracker host pending soft cap reached: host=%s pending=%d cap=%d" ,
2541+ host ,
2542+ host_pending ,
2543+ self ._pending_request_soft_cap_per_host ,
2544+ )
2545+ return None
2546+ effective_cap_pre = self ._get_effective_pending_request_cap ()
2547+ pending_pre = len (self .pending_requests )
2548+ pace_threshold = self ._udp_wait_pacing_load_ratio * effective_cap_pre
2549+ if effective_cap_pre > 0 and pending_pre > int (pace_threshold ):
2550+ # Pace new waits when the shared UDP client is heavily loaded so responses
2551+ # can drain before adding more in-flight transactions.
2552+ half_span = max (1.0 , pace_threshold )
2553+ pressure = min (
2554+ 1.0 ,
2555+ (pending_pre - pace_threshold ) / half_span ,
2556+ )
2557+ await asyncio .sleep (0.04 + 0.12 * pressure )
24962558 pruned_count = self ._prune_stale_pending_requests (
24972559 now = now , timeout = timeout , additional_new = 1
24982560 )
@@ -2523,11 +2585,14 @@ async def _wait_for_response(
25232585 )
25242586 self .pending_requests [transaction_id ] = future
25252587 self ._pending_request_timestamps [transaction_id ] = now
2588+ self ._pending_request_host_by_tid [transaction_id ] = host
25262589 if immediate_peers_callback is not None :
25272590 self .pending_immediate_callbacks [transaction_id ] = immediate_peers_callback
25282591 else :
25292592 self .pending_immediate_callbacks .pop (transaction_id , None )
25302593
2594+ self ._maybe_emit_udp_pending_gauge ()
2595+
25312596 try :
25322597 response = await asyncio .wait_for (future , timeout = adaptive_timeout )
25332598 elapsed = time .time () - start_wait
@@ -2566,6 +2631,7 @@ async def _wait_for_response(
25662631 self .pending_requests .pop (transaction_id , None )
25672632 self ._pending_request_timestamps .pop (transaction_id , None )
25682633 self .pending_immediate_callbacks .pop (transaction_id , None )
2634+ self ._pending_request_host_by_tid .pop (transaction_id , None )
25692635 self ._cleanup_stale_response_transaction_ids (now = time .time ())
25702636
25712637 @staticmethod
0 commit comments