@@ -17,7 +17,7 @@ use std::collections::binary_heap::PeekMut;
1717use std:: future:: Future ;
1818use std:: pin:: Pin ;
1919use std:: sync:: Arc ;
20- use std:: sync:: atomic:: { AtomicUsize , Ordering } ;
20+ use std:: sync:: atomic:: { AtomicU8 , AtomicUsize , Ordering } ;
2121use std:: task:: { Context , Poll } ;
2222use std:: time:: { Duration , Instant } ;
2323
@@ -58,6 +58,66 @@ pub(crate) struct SplitSearchTaskMetadata {
5858 pub job_cost : usize ,
5959}
6060
61+ /// Why the head of the permit queue could not be granted.
62+ ///
63+ /// The actor writes it into the shared [`BlockReasonHandle`] each time it fails to serve
64+ /// the head, so any waiter can read it whether its permit is eventually granted or the
65+ /// wait is cancelled (e.g. on a search deadline). This is what lets us attribute the
66+ /// acquisition latency to the binding resource.
67+ #[ derive( Clone , Copy , Debug , PartialEq , Eq ) ]
68+ pub enum PermitBlockReason {
69+ /// All concurrent warmup/download slots were in use.
70+ WarmupSlots ,
71+ /// The memory budget could not fit the request's estimated allocation.
72+ MemoryBudget ,
73+ }
74+
75+ impl PermitBlockReason {
76+ pub fn as_str ( & self ) -> & ' static str {
77+ match self {
78+ PermitBlockReason :: WarmupSlots => "warmup_slots" ,
79+ PermitBlockReason :: MemoryBudget => "memory_budget" ,
80+ }
81+ }
82+
83+ fn to_code ( self ) -> u8 {
84+ match self {
85+ PermitBlockReason :: WarmupSlots => 1 ,
86+ PermitBlockReason :: MemoryBudget => 2 ,
87+ }
88+ }
89+
90+ fn from_code ( code : u8 ) -> Option < Self > {
91+ match code {
92+ 1 => Some ( PermitBlockReason :: WarmupSlots ) ,
93+ 2 => Some ( PermitBlockReason :: MemoryBudget ) ,
94+ _ => None ,
95+ }
96+ }
97+ }
98+
99+ /// Shared cell recording the resource currently blocking the head of the permit queue.
100+ ///
101+ /// A single instance is shared by the actor (which writes it) and every
102+ /// [`SearchPermitFuture`] (which reads it). Because permits are served in order, whatever
103+ /// blocks the head effectively blocks every waiter, so a queue-level reason is correct for
104+ /// a waiter at any position — including one cancelled deep in the queue before it ever
105+ /// reaches the head. Uses an atomic so it can be read after the wait future is dropped on
106+ /// cancellation.
107+ #[ derive( Clone , Default ) ]
108+ pub struct BlockReasonHandle ( Arc < AtomicU8 > ) ;
109+
110+ impl BlockReasonHandle {
111+ fn set ( & self , reason : PermitBlockReason ) {
112+ self . 0 . store ( reason. to_code ( ) , Ordering :: Relaxed ) ;
113+ }
114+
115+ /// The resource currently blocking the queue, or `None` if it isn't blocked.
116+ pub fn get ( & self ) -> Option < PermitBlockReason > {
117+ PermitBlockReason :: from_code ( self . 0 . load ( Ordering :: Relaxed ) )
118+ }
119+ }
120+
61121pub enum SearchPermitMessage {
62122 RequestWithOffload {
63123 task_metadata : Vec < SplitSearchTaskMetadata > ,
@@ -116,6 +176,7 @@ impl SearchPermitProvider {
116176 permits_requests : BinaryHeap :: new ( ) ,
117177 total_memory_allocated : 0u64 ,
118178 total_job_cost : total_job_cost. clone ( ) ,
179+ block_reason : BlockReasonHandle :: default ( ) ,
119180 } ;
120181 let actor_join_handle = Arc :: new ( tokio:: spawn ( actor. run ( ) ) ) ;
121182 Self {
@@ -209,6 +270,9 @@ struct SearchPermitActor {
209270 /// Only the actor mutates it, so [`Ordering::Relaxed`] is sufficient.
210271 total_job_cost : Arc < AtomicUsize > ,
211272 permits_requests : BinaryHeap < LeafPermitRequest > ,
273+ /// Shared with every [`SearchPermitFuture`]; set to the resource blocking the head of
274+ /// the queue each time it can't be served.
275+ block_reason : BlockReasonHandle ,
212276}
213277
214278struct SingleSplitPermitRequest {
@@ -253,6 +317,7 @@ impl LeafPermitRequest {
253317 // `task_metadata` must not be empty.
254318 fn from_task_metadata (
255319 task_metadata : Vec < SplitSearchTaskMetadata > ,
320+ block_reason : BlockReasonHandle ,
256321 ) -> ( Self , Vec < SearchPermitFuture > ) {
257322 assert ! ( !task_metadata. is_empty( ) , "task_metadata must not be empty" ) ;
258323 // Stamped on every `SingleSplitPermitRequest` we're about to enqueue.
@@ -272,7 +337,10 @@ impl LeafPermitRequest {
272337 job_cost : meta. job_cost ,
273338 requested_at,
274339 } ) ;
275- permits. push ( SearchPermitFuture ( rx) ) ;
340+ permits. push ( SearchPermitFuture {
341+ receiver : rx,
342+ block_reason : block_reason. clone ( ) ,
343+ } ) ;
276344 }
277345 (
278346 LeafPermitRequest {
@@ -338,7 +406,7 @@ impl SearchPermitActor {
338406 SEARCHER_NODE_LOAD . set ( new_load as f64 ) ;
339407
340408 let ( leaf_permit_request, permit_futures) =
341- LeafPermitRequest :: from_task_metadata ( task_metadata) ;
409+ LeafPermitRequest :: from_task_metadata ( task_metadata, self . block_reason . clone ( ) ) ;
342410 self . permits_requests . push ( leaf_permit_request) ;
343411 self . assign_available_permits ( ) ;
344412 let _ = permit_sender. send ( permit_futures) ;
@@ -374,12 +442,25 @@ impl SearchPermitActor {
374442 }
375443
376444 fn pop_next_request_if_serviceable ( & mut self ) -> Option < SingleSplitPermitRequest > {
445+ // Nothing is waiting, so there is no block to attribute (avoids leaving a stale
446+ // reason set once the queue drains and slots/memory are exhausted).
447+ if self . permits_requests . is_empty ( ) {
448+ return None ;
449+ }
377450 if self . num_warmup_slots_available == 0 {
451+ self . block_reason . set ( PermitBlockReason :: WarmupSlots ) ;
378452 return None ;
379453 }
380- let available_memory = self
454+ let available_memory = match self
381455 . total_memory_budget
382- . checked_sub ( self . total_memory_allocated ) ?;
456+ . checked_sub ( self . total_memory_allocated )
457+ {
458+ Some ( available_memory) => available_memory,
459+ None => {
460+ self . block_reason . set ( PermitBlockReason :: MemoryBudget ) ;
461+ return None ;
462+ }
463+ } ;
383464 let mut peeked = self . permits_requests . peek_mut ( ) ?;
384465
385466 assert ! (
@@ -392,6 +473,8 @@ impl SearchPermitActor {
392473 }
393474 return Some ( permit_request) ;
394475 }
476+ // The head request needs more memory than is currently available.
477+ self . block_reason . set ( PermitBlockReason :: MemoryBudget ) ;
395478 None
396479 }
397480
@@ -510,13 +593,25 @@ impl Drop for SearchPermit {
510593 }
511594}
512595
513- pub struct SearchPermitFuture ( oneshot:: Receiver < SearchPermit > ) ;
596+ pub struct SearchPermitFuture {
597+ receiver : oneshot:: Receiver < SearchPermit > ,
598+ block_reason : BlockReasonHandle ,
599+ }
600+
601+ impl SearchPermitFuture {
602+ /// Handle to the reason this permit is blocked on. Readable at any time, including
603+ /// after this future is dropped on cancellation, so the waiter can attribute the
604+ /// wait even when the permit is never granted.
605+ pub fn block_reason_handle ( & self ) -> BlockReasonHandle {
606+ self . block_reason . clone ( )
607+ }
608+ }
514609
515610impl Future for SearchPermitFuture {
516611 type Output = SearchPermit ;
517612
518613 fn poll ( self : Pin < & mut Self > , cx : & mut Context < ' _ > ) -> Poll < Self :: Output > {
519- let receiver = Pin :: new ( & mut self . get_mut ( ) . 0 ) ;
614+ let receiver = Pin :: new ( & mut self . get_mut ( ) . receiver ) ;
520615 match receiver. poll ( cx) {
521616 Poll :: Ready ( Ok ( search_permit) ) => Poll :: Ready ( search_permit) ,
522617 Poll :: Ready ( Err ( _) ) => panic ! (
@@ -837,6 +932,52 @@ mod tests {
837932 permits. push ( try_get ( next_blocked_permit_fut) . await . unwrap ( ) ) ;
838933 }
839934
935+ #[ tokio:: test]
936+ async fn test_permit_block_reason_warmup_slots ( ) {
937+ // A single warmup slot with ample memory: once a second request is queued, the
938+ // queue can only be blocked on the warmup slot. The reason is queue-level and
939+ // shared by every waiter's handle, readable without being granted a permit.
940+ let permit_provider = SearchPermitProvider :: new ( 1 , ByteSize :: mb ( 1000 ) ) ;
941+ let first_fut = permit_provider
942+ . get_permits ( make_splits ( 10 , 1 ) )
943+ . await
944+ . into_iter ( )
945+ . next ( )
946+ . unwrap ( ) ;
947+ // First was granted immediately; nothing is blocked yet.
948+ assert_eq ! ( first_fut. block_reason_handle( ) . get( ) , None ) ;
949+ let second_fut = permit_provider
950+ . get_permits ( make_splits ( 10 , 1 ) )
951+ . await
952+ . into_iter ( )
953+ . next ( )
954+ . unwrap ( ) ;
955+ // Second can't get the single slot: the queue is now blocked on warmup slots.
956+ assert_eq ! (
957+ second_fut. block_reason_handle( ) . get( ) ,
958+ Some ( PermitBlockReason :: WarmupSlots )
959+ ) ;
960+ // Draining still works: freeing the slot lets the second request through.
961+ let first_permit = first_fut. await ;
962+ drop ( first_permit) ;
963+ let _second_permit = second_fut. await ;
964+ }
965+
966+ #[ tokio:: test]
967+ async fn test_permit_block_reason_memory_budget ( ) {
968+ // Ample slots but tight memory (100MB / 10MB = 10 permits): the 11th request can
969+ // only be blocked on the memory budget.
970+ let permit_provider = SearchPermitProvider :: new ( 100 , ByteSize :: mb ( 100 ) ) ;
971+ let permit_futs = permit_provider. get_permits ( make_splits ( 10 , 11 ) ) . await ;
972+ assert_eq ! ( permit_futs. len( ) , 11 ) ;
973+ // The 11th can't fit, so the queue is blocked on memory. The reason is queue-level
974+ // and shared, so any waiter's handle reports it — even one deep in the queue.
975+ assert_eq ! (
976+ permit_futs[ 10 ] . block_reason_handle( ) . get( ) ,
977+ Some ( PermitBlockReason :: MemoryBudget )
978+ ) ;
979+ }
980+
840981 #[ tokio:: test]
841982 async fn test_get_load_counts_queued_and_active ( ) {
842983 let permit_provider = SearchPermitProvider :: new ( 1 , ByteSize :: mb ( 100 ) ) ;
0 commit comments