@@ -32,8 +32,15 @@ pub struct QueryHit {
3232pub struct QueryOutcome {
3333 /// Total matches before offset/limit (es `totitems` semantics).
3434 pub total : u64 ,
35- /// The requested page of hits, sorted.
35+ /// The requested page of hits, ordered per [`Self:: sorted`] .
3636 pub hits : Vec < QueryHit > ,
37+ /// Whether [`Self::hits`] is already in sort order.
38+ ///
39+ /// `false` means the executor declined to order under the index lock and
40+ /// the CALLER must sort before any k-way merge that assumes sorted runs.
41+ /// Always `true` from [`run_query`], always `false` from
42+ /// [`run_query_unsorted`]; only [`run_query_deferrable`] varies.
43+ pub sorted : bool ,
3744}
3845
3946/// A [`ParsedQuery`]'s substring needles prebuilt into finders once (rather
@@ -138,7 +145,33 @@ pub fn run_query(
138145 offset : u32 ,
139146 limit : Option < u32 > ,
140147) -> QueryOutcome {
141- run_query_impl ( index, parsed, scope, Some ( sort) , offset, limit)
148+ run_query_impl ( index, parsed, scope, Some ( sort) , offset, limit, false )
149+ }
150+
151+ /// Like [`run_query`], but allowed to hand back an UNSORTED page when ordering
152+ /// under the index lock would buy nothing.
153+ ///
154+ /// Ordering inside the lock earns its keep only when the page is a strict slice
155+ /// of the match set: picking which `limit` matches survive needs the index, and
156+ /// only that many paths are then reconstructed. When the page already covers
157+ /// every match there is nothing to pick, so this returns the matches in scan
158+ /// order with [`QueryOutcome::sorted`] set to `false` and leaves the ordering to
159+ /// the caller, which can do it after releasing the lock.
160+ ///
161+ /// That distinction is not knowable before the scan (it depends on the match
162+ /// count), which is why it lives here rather than in the caller's dispatch.
163+ ///
164+ /// The caller MUST check [`QueryOutcome::sorted`] and sort when it is `false`,
165+ /// before any k-way merge that assumes sorted runs.
166+ pub fn run_query_deferrable (
167+ index : & VolumeIndex ,
168+ parsed : & ParsedQuery ,
169+ scope : Option < EntryIdx > ,
170+ sort : SortSpec ,
171+ offset : u32 ,
172+ limit : Option < u32 > ,
173+ ) -> QueryOutcome {
174+ run_query_impl ( index, parsed, scope, Some ( sort) , offset, limit, true )
142175}
143176
144177/// Every match, with paths built, in scan order. The CALLER sorts.
@@ -161,7 +194,7 @@ pub fn run_query_unsorted(
161194 parsed : & ParsedQuery ,
162195 scope : Option < EntryIdx > ,
163196) -> QueryOutcome {
164- run_query_impl ( index, parsed, scope, None , 0 , None )
197+ run_query_impl ( index, parsed, scope, None , 0 , None , false )
165198}
166199
167200/// A folder-scoped query walks the scope's subtree instead of scanning the
@@ -175,13 +208,18 @@ pub fn run_query_unsorted(
175208const SUBTREE_WALK_MAX : usize = 200_000 ;
176209
177210/// `sort: None` skips ordering entirely and returns every match.
211+ ///
212+ /// `allow_defer` lets the executor drop the ordering when the page turns out to
213+ /// cover every match (see [`run_query_deferrable`]); the reported
214+ /// [`QueryOutcome::sorted`] says which happened.
178215fn run_query_impl (
179216 index : & VolumeIndex ,
180217 parsed : & ParsedQuery ,
181218 scope : Option < EntryIdx > ,
182219 sort : Option < SortSpec > ,
183220 offset : u32 ,
184221 limit : Option < u32 > ,
222+ allow_defer : bool ,
185223) -> QueryOutcome {
186224 let compiled = CompiledQuery :: new ( parsed) ;
187225 let has_path_terms = !parsed. path_terms . is_empty ( ) ;
@@ -325,6 +363,27 @@ fn run_query_impl(
325363
326364 let total = candidates. len ( ) as u64 ;
327365
366+ let start = ( offset as usize ) . min ( candidates. len ( ) ) ;
367+ let end = match limit {
368+ Some ( n) => ( start + n as usize ) . min ( candidates. len ( ) ) ,
369+ None => candidates. len ( ) ,
370+ } ;
371+
372+ // A page that covers every match has nothing to select, so ordering it here
373+ // buys nothing the caller cannot do itself once it has released the index
374+ // lock. Dropping the sort in that case is the whole point of
375+ // `run_query_deferrable`: see `run_query_unsorted` for who is waiting on
376+ // that lock. `end` is already clamped to the match count, so this is exactly
377+ // "the page is the whole set".
378+ //
379+ // Everything below keys off the rebound `sort`, so a deferred query also
380+ // skips the path-key precompute it would never consult.
381+ let sort = match sort {
382+ Some ( s) if !( allow_defer && end == candidates. len ( ) ) => Some ( s) ,
383+ _ => None ,
384+ } ;
385+ let sorted = sort. is_some ( ) ;
386+
328387 // Sort-by-path: precompute each candidate's comparison key ONCE,
329388 // fold(parent dir path) + NUL + fold(name), so ordering is a plain byte
330389 // compare instead of re-folding both paths inside every comparison (the
@@ -356,12 +415,6 @@ fn run_query_impl(
356415 }
357416 }
358417
359- let start = ( offset as usize ) . min ( candidates. len ( ) ) ;
360- let end = match limit {
361- Some ( n) => ( start + n as usize ) . min ( candidates. len ( ) ) ,
362- None => candidates. len ( ) ,
363- } ;
364-
365418 // Order only enough to fill the page: quickselect the top `end` by the
366419 // sort key (O(M)), then sort just that prefix (O(end log end)). This avoids
367420 // an O(M log M) sort of the entire match set on common queries.
@@ -422,7 +475,11 @@ fn run_query_impl(
422475 } ) ;
423476 }
424477
425- QueryOutcome { total, hits }
478+ QueryOutcome {
479+ total,
480+ hits,
481+ sorted,
482+ }
426483}
427484
428485/// Verifies all predicates for one candidate, returning it (with a
@@ -1020,6 +1077,51 @@ mod tests {
10201077 assert_eq ! ( out. hits. len( ) , 2 ) ;
10211078 }
10221079
1080+ /// Ordering under the index lock earns its keep only when the limit really
1081+ /// slices the match set: picking the survivors needs the index, and only
1082+ /// that many paths get reconstructed. A page that already covers every
1083+ /// match has nothing to pick, so the executor must hand the ordering back
1084+ /// rather than do it while the caller holds the lock.
1085+ ///
1086+ /// Regression: the daemon chose by `limit.is_none()`, so a client asking for
1087+ /// everything as a NUMBER (`-n 4294967295`, or any limit at or above the
1088+ /// match count) took the in-lock path and sorted the entire set with the
1089+ /// volume's tail thread blocked behind it. The protection was one number
1090+ /// away from being skipped.
1091+ #[ test]
1092+ fn a_page_covering_every_match_defers_the_sort ( ) {
1093+ let idx = sample_index ( ) ;
1094+ let parsed = parse_query ( "" ) . unwrap ( ) ;
1095+ let sort = SortSpec :: default_for ( SortKey :: Name ) ;
1096+ let total = run_query ( & idx, & parsed, None , sort, 0 , None ) . total as u32 ;
1097+ assert ! ( total > 1 , "fixture must have several matches to slice" ) ;
1098+
1099+ for limit in [ None , Some ( u32:: MAX ) , Some ( total) , Some ( total + 1 ) ] {
1100+ let out = run_query_deferrable ( & idx, & parsed, None , sort, 0 , limit) ;
1101+ assert ! (
1102+ !out. sorted,
1103+ "limit {limit:?} covers all {total} matches; ordering must be deferred off-lock"
1104+ ) ;
1105+ assert_eq ! (
1106+ out. hits. len( ) ,
1107+ total as usize ,
1108+ "limit {limit:?}: a covering page still returns every match"
1109+ ) ;
1110+ }
1111+
1112+ // A limit that genuinely slices still orders here, where the index is
1113+ // available to pick which matches survive.
1114+ let sliced = run_query_deferrable ( & idx, & parsed, None , sort, 0 , Some ( total - 1 ) ) ;
1115+ assert ! ( sliced. sorted, "a real slice must still order in-lock" ) ;
1116+ assert_eq ! ( sliced. hits. len( ) , total as usize - 1 ) ;
1117+
1118+ // `run_query` keeps its unconditional promise for direct callers.
1119+ assert ! (
1120+ run_query( & idx, & parsed, None , sort, 0 , Some ( u32 :: MAX ) ) . sorted,
1121+ "run_query must always return sorted hits"
1122+ ) ;
1123+ }
1124+
10231125 #[ test]
10241126 fn case_sensitive_query_respects_case ( ) {
10251127 let idx = sample_index ( ) ;
0 commit comments