Skip to content

Commit 67a3ded

Browse files
loutPhilippsclaude
andcommitted
search: attribute permit-wait latency to the blocking resource
waiting_for_leaf_search_split_semaphore was a bare timer, so a long wait couldn't be attributed to the two things a split-search permit can block on: warmup/download slots or the memory budget. The actor now tracks the resource blocking the head of the queue in a shared BlockReasonHandle (an atomic). Because permits are served in order, whatever blocks the head blocks every waiter, so this queue-level reason is correct for a waiter at any position. Each SearchPermitFuture reads the handle, and a drop guard (WaitBlockReasonRecorder) on the wait span records blocked_on when the wait ends -- granted or cancelled -- gated on the wait being long (>=1ms) so instant grants stay unlabeled. Attributing on the wait side (not from the granted permit) is what lets the long, deadline-cancelled waits -- the ones we care about -- carry a reason even though they never get a permit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ecb2499 commit 67a3ded

2 files changed

Lines changed: 197 additions & 9 deletions

File tree

quickwit/quickwit-search/src/leaf.rs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ use crate::metrics::{
6767
};
6868
use crate::root::is_metadata_count_request_with_ast;
6969
use crate::search_permit_provider::{
70-
SearchPermit, SearchPermitFuture, compute_initial_memory_allocation,
70+
BlockReasonHandle, SearchPermit, SearchPermitFuture, compute_initial_memory_allocation,
7171
};
7272
use crate::service::{SearcherContext, deserialize_doc_mapper};
7373
use crate::{QuickwitAggregations, SearchError};
@@ -2047,6 +2047,42 @@ pub async fn single_doc_mapping_leaf_search(
20472047
Ok(leaf_search_response)
20482048
}
20492049

2050+
/// Records the permit block reason onto the `waiting_for_leaf_search_split_semaphore`
2051+
/// span when the wait ends.
2052+
///
2053+
/// Implemented as a drop guard (rather than reading after `.await`) so the reason is
2054+
/// recorded even when the wait future is cancelled before the permit is granted — the
2055+
/// long waits that hit a search deadline are exactly the ones that never get granted.
2056+
struct WaitBlockReasonRecorder {
2057+
wait_span: Span,
2058+
block_reason: BlockReasonHandle,
2059+
started_at: Instant,
2060+
}
2061+
2062+
impl WaitBlockReasonRecorder {
2063+
fn new(wait_span: Span, block_reason: BlockReasonHandle) -> Self {
2064+
Self {
2065+
wait_span,
2066+
block_reason,
2067+
started_at: Instant::now(),
2068+
}
2069+
}
2070+
}
2071+
2072+
impl Drop for WaitBlockReasonRecorder {
2073+
fn drop(&mut self) {
2074+
// Skip near-instant grants: a sub-millisecond wait isn't worth attributing and
2075+
// keeps the span uncluttered.
2076+
const MIN_WAIT_FOR_BLOCK_ATTRIBUTION: Duration = Duration::from_millis(1);
2077+
if self.started_at.elapsed() < MIN_WAIT_FOR_BLOCK_ATTRIBUTION {
2078+
return;
2079+
}
2080+
if let Some(block_reason) = self.block_reason.get() {
2081+
self.wait_span.record("blocked_on", block_reason.as_str());
2082+
}
2083+
}
2084+
}
2085+
20502086
async fn run_local_search_tasks(
20512087
local_search_tasks: Vec<LocalSearchTask>,
20522088
index_storage: Arc<dyn Storage + 'static>,
@@ -2069,7 +2105,18 @@ async fn run_local_search_tasks(
20692105
split_id = split.split_id,
20702106
num_docs = split.num_docs
20712107
);
2072-
let wait_span = info_span!(parent: &split_span, "waiting_for_leaf_search_split_semaphore");
2108+
let wait_span = info_span!(
2109+
parent: &split_span,
2110+
"waiting_for_leaf_search_split_semaphore",
2111+
blocked_on = tracing::field::Empty
2112+
);
2113+
// Records the block reason on `wait_span` when the wait ends — whether the permit
2114+
// is granted or the wait is cancelled on a search deadline (the important, long
2115+
// waits are exactly the ones that get cancelled before being granted).
2116+
let _block_reason_recorder = WaitBlockReasonRecorder::new(
2117+
wait_span.clone(),
2118+
search_permit_future.block_reason_handle(),
2119+
);
20732120
let leaf_split_search_permit = search_permit_future.instrument(wait_span).await;
20742121

20752122
// We run simplify search request again: as we push split into the merge collector,

quickwit/quickwit-search/src/search_permit_provider.rs

Lines changed: 148 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use std::collections::binary_heap::PeekMut;
1717
use std::future::Future;
1818
use std::pin::Pin;
1919
use std::sync::Arc;
20-
use std::sync::atomic::{AtomicUsize, Ordering};
20+
use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
2121
use std::task::{Context, Poll};
2222
use 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+
61121
pub 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

214278
struct 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

515610
impl 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

Comments
 (0)