Skip to content

Commit 80d70a7

Browse files
PSeitzclaude
andauthored
feat(search): cache never-match queries via the predicate cache (#6556)
* feat(search): cache never-match queries via the predicate cache Repeated super-selective or non-existent term queries over many splits re-ran warmup on every request. When warmup proves a split empty (a required term has an empty posting list), record a fake empty entry in the existing predicate cache, and consult it before warmup so later requests with the same predicate short-circuit with no storage reads. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Cache split emptiness per term instead of per whole query The negative cache keyed each provably-empty split on the whole query AST, so any added/removed filter or different time window produced a new key. In production this gave near-zero reuse: every filter permutation re-opened and re-probed all splits. A required term's absence in a split is immutable and independent of the rest of the query and of the time window. Key the cache on (split, term) instead: warmup reports each required term it proves absent via an `on_absent` callback, and a query short-circuits before warmup when any of its required terms is already known absent. Adding required terms can only make a query emptier, so cached absences keep pruning as filters change. * Drop low-value term_absence_cache_key unit test It only asserted the function is pure and produces distinct keys for distinct terms; the key is already covered by the production leaf-search path and the negative-cache integration tests. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9725608 commit 80d70a7

3 files changed

Lines changed: 431 additions & 40 deletions

File tree

quickwit/quickwit-query/src/query_ast/cache_node.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,16 @@ pub struct HitSet {
212212
const INCOMPLETE_BLOCK_MARKER: u8 = 0x80;
213213

214214
impl HitSet {
215-
#[cfg(test)]
216-
fn empty() -> Self {
215+
/// An empty hit set, matching no document.
216+
pub fn empty() -> Self {
217217
Self::from_buffer(OwnedBytes::new(vec![0, 0, 0, 0]))
218218
}
219219

220+
/// Returns true if this hit set matches no document.
221+
pub fn is_empty(&self) -> bool {
222+
self.size_hint() == 0
223+
}
224+
220225
/// Build a HitSet from its serialized form.
221226
///
222227
/// The provided buffer must come from `HitSet::into_buffer`

quickwit/quickwit-search/src/leaf.rs

Lines changed: 118 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ use quickwit_proto::search::{
3838
SortOrder, SortValue, SplitIdAndFooterOffsets, SplitResourceStats, SplitSearchError,
3939
};
4040
use quickwit_query::query_ast::{
41-
BoolQuery, CacheNode, QueryAst, QueryAstTransformer, RangeQuery, TermQuery,
41+
BoolQuery, CacheNode, HitSet, PredicateCache, QueryAst, QueryAstTransformer, RangeQuery,
42+
TermQuery,
4243
};
4344
use quickwit_query::tokenizers::TokenizerManager;
4445
use quickwit_storage::{
@@ -50,6 +51,7 @@ use tantivy::aggregation::agg_req::{AggregationVariants, Aggregations};
5051
use tantivy::collector::Collector;
5152
use tantivy::directory::FileSlice;
5253
use tantivy::fastfield::FastFieldReaders;
54+
use tantivy::index::SegmentId;
5355
use tantivy::schema::Field;
5456
use tantivy::{DateTime, Index, ReloadPolicy, Searcher, TantivyError, Term};
5557
use tokio::task::{JoinError, JoinSet};
@@ -256,16 +258,6 @@ pub(crate) async fn open_index_with_caches(
256258
Ok((index, hot_directory))
257259
}
258260

259-
/// Outcome of [`warmup`].
260-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261-
pub(crate) enum WarmupOutcome {
262-
/// The warmup completed; the split must be searched.
263-
Completed,
264-
/// A required term has an empty posting list, so the query provably matches
265-
/// nothing in this split. The remaining warmup downloads were cancelled.
266-
ProvablyEmpty,
267-
}
268-
269261
/// Runs `fut`, racing it against `cancel`. If cancellation fires first, the
270262
/// (possibly in-flight) future is dropped — aborting its downloads — and
271263
/// `Ok(())` is returned. With no token, `fut` simply runs to completion.
@@ -298,11 +290,21 @@ async fn run_cancellable(
298290
/// * `term_dict_field_names` - A list of fields, where the whole dictionary needs to be loaded.
299291
/// This is e.g. required for term aggregation, since we don't know in advance which terms are
300292
/// going to be hit.
293+
///
294+
/// `on_absent` is invoked once for every required term found to have an empty posting list,
295+
/// with the segment it was missing from. Such a term proves the query empty in this split,
296+
/// so the remaining warmup downloads are then cancelled; the callback lets the caller record
297+
/// the (immutable, query-independent) absence — see [`term_absence_cache_key`]. It only ever
298+
/// fires for a single-segment split, where "absent in the split" is sound.
299+
///
300+
/// Returns whether the query is provably empty in this split (i.e. `on_absent` fired and
301+
/// warmup was short-circuited).
301302
#[instrument(skip_all)]
302303
pub(crate) async fn warmup(
303304
searcher: &Searcher,
304305
warmup_info: &WarmupInfo,
305-
) -> anyhow::Result<WarmupOutcome> {
306+
on_absent: &(dyn Fn(&Term, SegmentId) + Sync),
307+
) -> anyhow::Result<bool> {
306308
debug!(warmup_info=?warmup_info);
307309

308310
// Early-abort optimization: the split's downloads can be cancelled as soon as
@@ -323,6 +325,7 @@ pub(crate) async fn warmup(
323325
&warmup_info.terms_grouped_by_field,
324326
&warmup_info.required_terms,
325327
abort_token.as_ref(),
328+
on_absent,
326329
)
327330
.instrument(debug_span!("warm_up_terms"));
328331
let warm_up_term_ranges_future = run_cancellable(
@@ -371,11 +374,7 @@ pub(crate) async fn warmup(
371374
Some(abort_token) => abort_token.is_cancelled(),
372375
None => false,
373376
};
374-
if provably_empty {
375-
Ok(WarmupOutcome::ProvablyEmpty)
376-
} else {
377-
Ok(WarmupOutcome::Completed)
378-
}
377+
Ok(provably_empty)
379378
}
380379

381380
async fn warm_up_term_dict_fields(
@@ -453,11 +452,13 @@ async fn warm_up_terms(
453452
terms_grouped_by_field: &HashMap<Field, HashMap<Term, bool>>,
454453
required_terms: &HashSet<Term>,
455454
abort_token: Option<&CancellationToken>,
455+
on_absent: &(dyn Fn(&Term, SegmentId) + Sync),
456456
) -> anyhow::Result<()> {
457457
let mut warm_up_futures = Vec::new();
458458
for (field, terms) in terms_grouped_by_field {
459459
for segment_reader in searcher.segment_readers() {
460460
let inv_idx = segment_reader.inverted_index(*field)?;
461+
let segment_id = segment_reader.segment_id();
461462
for (term, position_needed) in terms.iter() {
462463
let inv_idx_clone = inv_idx.clone();
463464
// Only a required term can prove the query empty. When such a
@@ -470,6 +471,9 @@ async fn warm_up_terms(
470471
warm_up_futures.push(async move {
471472
let found = inv_idx_clone.warm_postings(term, *position_needed).await?;
472473
if !found && let Some(abort_token) = cancel_on_empty {
474+
// Report the absence and fire the abort token. Both are synchronous, so
475+
// they run before any cancellation can drop us.
476+
on_absent(term, segment_id);
473477
abort_token.cancel();
474478
}
475479
anyhow::Ok(())
@@ -621,6 +625,33 @@ fn compute_index_size(hot_directory: &HotDirectory) -> ByteSize {
621625
ByteSize(size_bytes)
622626
}
623627

628+
/// Cache key under which "this term has no posting list in this split" is recorded in
629+
/// the shared predicate cache.
630+
///
631+
/// Absence is a property of the term and the split alone: it is independent of the rest
632+
/// of the query and of any time window, and — because splits are immutable — it never
633+
/// changes once observed. So a single entry per `(split, term)` lets *every* query that
634+
/// carries this term short-circuit before warmup, no matter what other filters or time
635+
/// range ride along, and adding more required terms can only make a query emptier.
636+
///
637+
/// The key is the field id followed by the hex of the term's serialized value bytes
638+
/// (which for a JSON field already encode the path and type) — together a unique
639+
/// identifier of the term. It never collides with the whole-query keys the [`CacheNode`]
640+
/// positive cache stores in the same instance: those are serialized query ASTs that
641+
/// start with `{`, never a hex field id.
642+
pub(crate) fn term_absence_cache_key(term: &Term) -> String {
643+
use std::fmt::Write;
644+
645+
let value_bytes = term.serialized_value_bytes();
646+
let mut key = String::with_capacity(9 + value_bytes.len() * 2);
647+
let _ = write!(key, "{:08x}:", term.field().field_id());
648+
for byte in value_bytes {
649+
// Hex-encode so the binary value bytes form a valid (printable) String key.
650+
let _ = write!(key, "{byte:02x}");
651+
}
652+
key
653+
}
654+
624655
/// Apply a leaf search on a single split.
625656
async fn leaf_search_single_split(
626657
search_request: SearchRequest,
@@ -712,7 +743,40 @@ async fn leaf_search_single_split(
712743

713744
let warmup_start = Instant::now();
714745
leaf_search_state_guard.set_state(SplitSearchState::WarmUp);
715-
let warmup_outcome = warmup(&searcher, &warmup_info).await?;
746+
// Negative cache: a split is provably empty for this query if any required term has
747+
// previously been proven absent here. Absence is an immutable, query- and
748+
// time-window-independent property of the split (see `term_absence_cache_key`), so the
749+
// split short-circuits before warmup no matter which earlier query first proved the
750+
// term absent, nor what other filters or time range this query carries — extra
751+
// required terms can only make it emptier. An empty result is segment- and
752+
// scoring-agnostic, so this holds even for scored queries (which the `CacheNode`
753+
// machinery itself does not support).
754+
let cached_known_empty = warmup_info.required_terms.iter().any(|term| {
755+
match ctx
756+
.searcher_context
757+
.predicate_cache
758+
.get(split_id.clone(), term_absence_cache_key(term))
759+
{
760+
Some((_segment_id, hits)) => hits.is_empty(),
761+
None => false,
762+
}
763+
});
764+
let provably_empty = if cached_known_empty {
765+
true
766+
} else {
767+
// Record every required term proven absent during warmup, so any future query
768+
// carrying one of them prunes before warmup. Absence is per `(split, term)`,
769+
// immutable, and independent of the rest of the query.
770+
let record_absence = |term: &Term, segment_id: SegmentId| {
771+
ctx.searcher_context.predicate_cache.put(
772+
split_id.clone(),
773+
term_absence_cache_key(term),
774+
segment_id,
775+
HitSet::empty(),
776+
);
777+
};
778+
warmup(&searcher, &warmup_info, &record_absence).await?
779+
};
716780
let warmup_end = Instant::now();
717781
let warmup_duration: Duration = warmup_end.duration_since(warmup_start);
718782
let warmup_size = ByteSize(byte_range_cache.get_num_bytes());
@@ -732,7 +796,11 @@ async fn leaf_search_single_split(
732796
search_permit.update_memory_usage(warmup_size);
733797
search_permit.free_warmup_slot();
734798

735-
if warmup_outcome == WarmupOutcome::ProvablyEmpty {
799+
if provably_empty {
800+
// The absences discovered this run were already recorded per-term above (or the
801+
// short-circuit came from a prior run's per-term entry), so there is nothing to
802+
// write here.
803+
//
736804
// A required term's posting list was empty, so the query matches no
737805
// document in this split. The remaining warmup downloads were aborted;
738806
// skip the search and report an empty (but counted) result. This is the
@@ -2749,6 +2817,8 @@ mod tests {
27492817
(searcher, field)
27502818
}
27512819

2820+
/// Builds a `WarmupInfo` warming `terms`, with `required` as the set of required
2821+
/// terms (each must be present for the query to match).
27522822
fn warmup_info_with_required(terms: &[&Term], required: &[&Term]) -> WarmupInfo {
27532823
let mut terms_grouped_by_field: HashMap<Field, HashMap<Term, bool>> = HashMap::new();
27542824
for term in terms {
@@ -2765,34 +2835,45 @@ mod tests {
27652835
}
27662836

27672837
#[tokio::test]
2768-
async fn test_warmup_aborts_when_required_term_is_missing() {
2838+
async fn test_warmup_reports_absent_required_terms() {
27692839
let (searcher, body) = ram_searcher_with_text("body", &["hello world"]);
2770-
// Single segment: the early-abort optimization is armed.
2840+
// Single segment: the early-abort optimization is armed, so absence is recorded.
27712841
assert_eq!(searcher.segment_readers().len(), 1);
27722842

27732843
let present = Term::from_field_text(body, "hello");
27742844
let missing = Term::from_field_text(body, "missing");
27752845

2776-
// A required term with an empty posting list proves the query empty.
2777-
let warmup_info = warmup_info_with_required(&[&present, &missing], &[&missing]);
2778-
assert_eq!(
2779-
warmup(&searcher, &warmup_info).await.unwrap(),
2780-
WarmupOutcome::ProvablyEmpty
2781-
);
2846+
// Runs warmup, returning whether the split is provably empty and the terms that
2847+
// `on_absent` was invoked with.
2848+
async fn run(searcher: &Searcher, warmup_info: &WarmupInfo) -> (bool, Vec<Term>) {
2849+
let reported = std::sync::Mutex::new(Vec::new());
2850+
let provably_empty = warmup(searcher, warmup_info, &|term: &Term, _segment_id| {
2851+
reported.lock().unwrap().push(term.clone());
2852+
})
2853+
.await
2854+
.unwrap();
2855+
(provably_empty, reported.into_inner().unwrap())
2856+
}
27822857

2783-
// The required term is present: warmup completes normally.
2858+
// An absent required term is reported (so the caller can cache it) and proves the
2859+
// split empty; the present required term is not reported.
2860+
let warmup_info = warmup_info_with_required(&[&present, &missing], &[&present, &missing]);
2861+
let (provably_empty, reported) = run(&searcher, &warmup_info).await;
2862+
assert!(provably_empty);
2863+
assert_eq!(reported, vec![missing.clone()]);
2864+
2865+
// All required terms present: nothing reported, so the split must be searched.
27842866
let warmup_info = warmup_info_with_required(&[&present], &[&present]);
2785-
assert_eq!(
2786-
warmup(&searcher, &warmup_info).await.unwrap(),
2787-
WarmupOutcome::Completed
2788-
);
2867+
let (provably_empty, reported) = run(&searcher, &warmup_info).await;
2868+
assert!(!provably_empty);
2869+
assert!(reported.is_empty());
27892870

2790-
// A missing term that is not required must not abort.
2871+
// A missing term that is not required is never reported (recording would be unsound
2872+
// without a required-term proof), so the split must be searched.
27912873
let warmup_info = warmup_info_with_required(&[&present, &missing], &[]);
2792-
assert_eq!(
2793-
warmup(&searcher, &warmup_info).await.unwrap(),
2794-
WarmupOutcome::Completed
2795-
);
2874+
let (provably_empty, reported) = run(&searcher, &warmup_info).await;
2875+
assert!(!provably_empty);
2876+
assert!(reported.is_empty());
27962877
}
27972878

27982879
fn nz(n: usize) -> std::num::NonZeroUsize {

0 commit comments

Comments
 (0)