Skip to content

Commit 5290d75

Browse files
committed
fix: defer the sort when a limit covers every match
The daemon chose whether to order under the index read lock by testing `limit.is_none()`. A client asking for everything as a number, `-n 4294967295` or any limit at or above the match count, therefore took the in-lock path and sorted the whole match set while holding the lock, re-introducing the exact stall the deferral exists to prevent. The protection was one number away from being skipped. Ordering under the lock only pays when the limit genuinely slices the set: picking the survivors needs the index, and only that many paths get reconstructed. Whether it does is not knowable before the scan, so the executor decides and reports back through QueryOutcome::sorted, and the server orders off the lock whenever the executor declined to. Blocked indexing on a 2.1M-match query drops from ~261 ms to ~140 ms.
1 parent 0fbe2b8 commit 5290d75

3 files changed

Lines changed: 182 additions & 28 deletions

File tree

crates/goz-core/src/query/engine.rs

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,15 @@ pub struct QueryHit {
3232
pub 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(
175208
const 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.
178215
fn 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();

crates/goz-core/src/query/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub mod parse;
1414
pub use crate::fold;
1515

1616
pub use engine::{
17-
CompiledQuery, QueryHit, QueryOutcome, resolve_scope, run_query, run_query_unsorted,
17+
CompiledQuery, QueryHit, QueryOutcome, resolve_scope, run_query, run_query_deferrable,
18+
run_query_unsorted,
1819
};
1920
pub use parse::{Filters, Kind, ParsedQuery, QueryError, SizeRange, Wildcard, parse_query};

crates/goz-daemon/src/server.rs

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use goz_core::proto::{
1818
MAX_CLIENT_FRAME, PAGE_ROWS, QueryRequest, encode_response_frame, push_item,
1919
push_results_header,
2020
};
21-
use goz_core::query::{parse_query, resolve_scope, run_query, run_query_unsorted};
21+
use goz_core::query::{parse_query, resolve_scope, run_query_deferrable};
2222
use goz_core::types::{EntryIdx, SortKey, SortSpec};
2323
use goz_winfs::{PipeSecurity, build_pipe_security};
2424
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -269,31 +269,34 @@ fn stream_query_binary(
269269
None => continue, // scope folder absent on this volume: no hits
270270
},
271271
};
272-
// A limited query must order INSIDE the lock: it needs the index to pick
273-
// which `per_vol_limit` matches survive, and it only reconstructs that
274-
// many paths.
272+
// A limited query orders INSIDE the lock only when the limit actually
273+
// slices the match set: picking which `per_vol_limit` matches survive
274+
// needs the index, and only that many paths are then reconstructed.
275275
//
276-
// A full export has no such need. It returns every match, so ordering is
277-
// pure post-processing over owned rows, and every sort key comes from the
278-
// hit itself (`sort_collected` touches no index). Sorting a million rows
279-
// under the read lock blocked this volume's tail thread from applying a
280-
// single journal record for the whole sort; doing it after the guard
281-
// drops blocks nobody.
282-
let deferred_sort = per_vol_limit.is_none();
283-
let outcome = if deferred_sort {
284-
run_query_unsorted(&index, &parsed, scope_entry)
285-
} else {
286-
run_query(&index, &parsed, scope_entry, q.sort, 0, per_vol_limit)
287-
};
276+
// When the page covers every match there is nothing to pick, so ordering
277+
// is pure post-processing over owned rows and every sort key comes from
278+
// the hit itself (`sort_collected` touches no index). Sorting a million
279+
// rows under the read lock blocked this volume's tail thread from
280+
// applying a single journal record for the whole sort; doing it after
281+
// the guard drops blocks nobody.
282+
//
283+
// Which case applies depends on the match count, which is not known
284+
// until the scan has run, so the executor decides and reports back via
285+
// `outcome.sorted`. Testing `limit.is_none()` here instead meant a
286+
// client asking for everything as a NUMBER (`-n 4294967295`, or any
287+
// limit at or above the match count) took the in-lock path and
288+
// re-introduced the very stall the deferral exists to prevent.
289+
let outcome = run_query_deferrable(&index, &parsed, scope_entry, q.sort, 0, per_vol_limit);
288290
total += outcome.total;
291+
let sorted = outcome.sorted;
289292
drop(index); // hits own their bytes; release the read lock early
290293

291294
let mut run: Vec<_> = outcome
292295
.hits
293296
.into_iter()
294297
.map(|hit| (vol.clone(), hit))
295298
.collect();
296-
if deferred_sort {
299+
if !sorted {
297300
// Off the lock. `order_merged` k-way merges runs it assumes are
298301
// already sorted, so this is not optional.
299302
sort_collected(&mut run, q.sort);
@@ -1133,6 +1136,54 @@ mod tests {
11331136
);
11341137
}
11351138

1139+
/// Every emitted path, in wire order, across all pages.
1140+
fn paths(bytes: &[u8]) -> Vec<String> {
1141+
results(bytes)
1142+
.into_iter()
1143+
.flat_map(|p| p.items.into_iter().map(|i| i.path))
1144+
.collect()
1145+
}
1146+
1147+
/// A limit at or above the match count means "everything", so it must return
1148+
/// exactly what no limit returns, in the same order, under every sort key
1149+
/// and direction.
1150+
///
1151+
/// The deferral that makes the covering-limit case cheap changes WHERE the
1152+
/// ordering happens (off the index lock instead of under it). This pins that
1153+
/// it does not change WHAT comes back. Before the fix the two spellings took
1154+
/// different code paths entirely, the engine's comparator versus the
1155+
/// server's, which is exactly how their tie-ordering could drift apart
1156+
/// unnoticed.
1157+
#[test]
1158+
fn a_covering_limit_returns_the_same_page_as_no_limit() {
1159+
use goz_core::types::SortDir;
1160+
let vols = set(&[volume(r"C:\", merge_index(), VolumePhase::Live)]);
1161+
for key in [
1162+
SortKey::Name,
1163+
SortKey::Path,
1164+
SortKey::Size,
1165+
SortKey::DateModified,
1166+
] {
1167+
for dir in [SortDir::Asc, SortDir::Desc] {
1168+
let page = |limit| {
1169+
let mut q = query("log");
1170+
q.sort = SortSpec { key, dir };
1171+
q.limit = limit;
1172+
paths(&encode_query_binary(&vols, q))
1173+
};
1174+
let unlimited = page(None);
1175+
assert!(!unlimited.is_empty(), "fixture must produce hits");
1176+
for limit in [u32::MAX, unlimited.len() as u32, unlimited.len() as u32 + 1] {
1177+
assert_eq!(
1178+
page(Some(limit)),
1179+
unlimited,
1180+
"{key:?}/{dir:?}: -n {limit} covers the set and must equal no limit"
1181+
);
1182+
}
1183+
}
1184+
}
1185+
}
1186+
11361187
fn hello_request(proto_min: u16, proto_max: u16) -> Request {
11371188
Request::Hello {
11381189
proto_min,

0 commit comments

Comments
 (0)