@@ -38,7 +38,8 @@ use quickwit_proto::search::{
3838 SortOrder , SortValue , SplitIdAndFooterOffsets , SplitResourceStats , SplitSearchError ,
3939} ;
4040use quickwit_query:: query_ast:: {
41- BoolQuery , CacheNode , QueryAst , QueryAstTransformer , RangeQuery , TermQuery ,
41+ BoolQuery , CacheNode , HitSet , PredicateCache , QueryAst , QueryAstTransformer , RangeQuery ,
42+ TermQuery ,
4243} ;
4344use quickwit_query:: tokenizers:: TokenizerManager ;
4445use quickwit_storage:: {
@@ -50,6 +51,7 @@ use tantivy::aggregation::agg_req::{AggregationVariants, Aggregations};
5051use tantivy:: collector:: Collector ;
5152use tantivy:: directory:: FileSlice ;
5253use tantivy:: fastfield:: FastFieldReaders ;
54+ use tantivy:: index:: SegmentId ;
5355use tantivy:: schema:: Field ;
5456use tantivy:: { DateTime , Index , ReloadPolicy , Searcher , TantivyError , Term } ;
5557use 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) ]
302303pub ( 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
381380async 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.
625656async 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