Skip to content

Commit 6169313

Browse files
committed
fix cache flush in aggregations
fixes #2992 ``` full terms_7 Memory: 37.2 KB Avg: 2.3958ms (+0.31%) Median: 2.3896ms (+0.18%) [2.3573ms .. 2.5176ms] terms_all_unique Memory: 10.8 MB Avg: 5.5144ms (-1.07%) Median: 5.4625ms (-1.98%) [5.3364ms .. 5.9712ms] terms_all_unique_order_by_key Memory: 10.8 MB Avg: 5.2614ms (-0.85%) Median: 5.2177ms (-1.21%) [5.0823ms .. 5.6316ms] terms_150_000 Memory: 2.7 MB Avg: 5.5335ms (-1.07%) Median: 5.5152ms (-1.06%) [5.4151ms .. 5.9654ms] terms_many_top_1000 Memory: 5.2 MB Avg: 8.3579ms (-1.53%) Median: 8.3604ms (-0.95%) [8.2184ms .. 8.5421ms] terms_many_order_by_term Memory: 2.7 MB Avg: 4.6713ms (-0.07%) Median: 4.6569ms (-0.15%) [4.5994ms .. 4.9115ms] terms_all_unique_with_avg_sub_agg Memory: 54.0 MB Avg: 17.4981ms (-2.43%) Median: 17.6075ms (-1.75%) [15.8166ms .. 18.9250ms] terms_status_with_avg_sub_agg Memory: 90.3 KB Avg: 5.6365ms (+7.77%) Median: 5.6255ms (+7.97%) [5.5489ms .. 5.8254ms] terms_status_with_terms_zipf_1000_sub_agg Memory: 318.5 KB (+56.52%) Avg: 4.4504ms (+11.55%) Median: 4.4436ms (+11.59%) [4.3858ms .. 4.5692ms] terms_zipf_1000_with_terms_status_sub_agg Memory: 684.9 KB Avg: 11.8606ms (+0.19%) Median: 11.8360ms (-0.02%) [11.7478ms .. 12.0609ms] terms_status_with_histogram Memory: 139.5 KB Avg: 2.4524ms (-1.09%) Median: 2.4521ms (-0.23%) [2.4179ms .. 2.5049ms] terms_status_with_date_histogram Memory: 136.7 KB Avg: 2.3407ms (-1.28%) Median: 2.3359ms (-1.06%) [2.3001ms .. 2.4310ms] terms_status_with_date_histogram_hard_bounds Memory: 136.1 KB Avg: 2.5113ms (-2.04%) Median: 2.5073ms (-0.97%) [2.4455ms .. 2.7280ms] terms_status_with_date_histogram_and_sibling_terms Memory: 137.3 KB Avg: 3.8695ms (-0.48%) Median: 3.8653ms (+0.11%) [3.8093ms .. 4.0528ms] terms_zipf_1000 Memory: 69.8 KB Avg: 2.2022ms (-1.95%) Median: 2.2026ms (-1.12%) [2.1705ms .. 2.2859ms] terms_zipf_1000_with_histogram Memory: 1.2 MB Avg: 20.4087ms (-0.02%) Median: 20.3665ms (+0.11%) [20.1912ms .. 20.7933ms] terms_zipf_1000_with_avg_sub_agg Memory: 472.0 KB Avg: 8.7387ms (-3.48%) Median: 8.7043ms (-3.44%) [8.6466ms .. 9.1396ms] terms_zipf_90 Memory: 55.3 KB Avg: 1.3784ms (-2.04%) Median: 1.3787ms (-1.61%) [1.3484ms .. 1.4611ms] terms_zipf_90_with_sum_sub_agg Memory: 367.6 KB Avg: 4.8520ms (+8.43%) Median: 4.8326ms (+8.94%) [4.8058ms .. 5.1278ms] terms_many_json_mixed_type_with_avg_sub_agg Memory: 17.8 MB Avg: 25.0853ms (-7.70%) Median: 25.0591ms (-7.12%) [24.8103ms .. 25.4936ms] terms_status_with_cardinality_agg Memory: 91.8 KB Avg: 3.3667ms (+1.47%) Median: 3.3690ms (+1.66%) [3.3311ms .. 3.4070ms] terms_100_buckets_with_cardinality_agg Memory: 9.9 MB Avg: 48.4768ms (-3.07%) Median: 48.3745ms (-3.38%) [48.1425ms .. 49.5503ms] ```
1 parent 7152d53 commit 6169313

3 files changed

Lines changed: 115 additions & 29 deletions

File tree

benches/agg_bench.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ fn bench_agg(mut group: InputGroup<Index>) {
7373
register!(group, terms_zipf_1000);
7474
register!(group, terms_zipf_1000_with_histogram);
7575
register!(group, terms_zipf_1000_with_avg_sub_agg);
76+
register!(group, terms_zipf_90);
77+
register!(group, terms_zipf_90_with_sum_sub_agg);
7678

7779
register!(group, terms_many_json_mixed_type_with_avg_sub_agg);
7880

@@ -495,6 +497,27 @@ fn terms_zipf_1000(index: &Index) {
495497
execute_agg(index, agg_req);
496498
}
497499

500+
fn terms_zipf_90(index: &Index) {
501+
let agg_req = json!({
502+
"my_texts": { "terms": { "field": "text_90_terms_zipf", "size": 100 } },
503+
});
504+
execute_agg(index, agg_req);
505+
}
506+
507+
// 90-term (low-cardinality Vec path) terms agg with a metric sub-agg. The skewed distribution keeps
508+
// a dominant bucket crossing the sub-agg flush threshold, exercising the buffer flush path.
509+
fn terms_zipf_90_with_sum_sub_agg(index: &Index) {
510+
let agg_req = json!({
511+
"my_texts": {
512+
"terms": { "field": "text_90_terms_zipf", "size": 100 },
513+
"aggs": {
514+
"sum_score": { "sum": { "field": "score" } }
515+
}
516+
},
517+
});
518+
execute_agg(index, agg_req);
519+
}
520+
498521
fn terms_many_json_mixed_type_with_avg_sub_agg(index: &Index) {
499522
let agg_req = json!({
500523
"my_texts": {
@@ -762,6 +785,8 @@ fn get_test_index_bench(cardinality: Cardinality) -> tantivy::Result<Index> {
762785
let text_field_few_terms = schema_builder.add_text_field("text_few_terms", STRING | FAST);
763786
let text_field_few_terms_status =
764787
schema_builder.add_text_field("text_few_terms_status", STRING | FAST);
788+
let text_field_90_terms_zipf =
789+
schema_builder.add_text_field("text_90_terms_zipf", STRING | FAST);
765790
let text_field_1000_terms_zipf =
766791
schema_builder.add_text_field("text_1000_terms_zipf", STRING | FAST);
767792
let score_fieldtype = tantivy::schema::NumericOptions::default().set_fast();
@@ -800,6 +825,12 @@ fn get_test_index_bench(cardinality: Cardinality) -> tantivy::Result<Index> {
800825
let terms_1000: Vec<String> = (1..=1000).map(|i| format!("term_{i}")).collect();
801826
let zipf_1000 = rand_distr::Zipf::new(1000.0, 1.1f64).unwrap();
802827

828+
// 90 terms (< MAX_NUM_TERMS_FOR_VEC), skewed via Zipf so a dominant bucket keeps crossing the
829+
// sub-agg flush threshold while minority buckets stay small. Exercises the low-cardinality
830+
// sub-agg buffer flush path (see issue #2992).
831+
let terms_90: Vec<String> = (1..=90).map(|i| format!("term_{i}")).collect();
832+
let zipf_90 = rand_distr::Zipf::new(90.0, 1.1f64).unwrap();
833+
803834
{
804835
let mut rng = StdRng::from_seed([1u8; 32]);
805836
let mut index_writer = index.writer_with_num_threads(1, 200_000_000)?;
@@ -815,6 +846,8 @@ fn get_test_index_bench(cardinality: Cardinality) -> tantivy::Result<Index> {
815846
let idx_b = zipf_1000.sample(&mut rng) as usize - 1;
816847
let term_1000_a = &terms_1000[idx_a];
817848
let term_1000_b = &terms_1000[idx_b];
849+
let term_90_a = &terms_90[zipf_90.sample(&mut rng) as usize - 1];
850+
let term_90_b = &terms_90[zipf_90.sample(&mut rng) as usize - 1];
818851
index_writer.add_document(doc!(
819852
json_field => json!({"mixed_type": 10.0}),
820853
json_field => json!({"mixed_type": 10.0}),
@@ -830,6 +863,8 @@ fn get_test_index_bench(cardinality: Cardinality) -> tantivy::Result<Index> {
830863
text_field_few_terms => "cool",
831864
text_field_few_terms_status => log_level_sample_a,
832865
text_field_few_terms_status => log_level_sample_b,
866+
text_field_90_terms_zipf => term_90_a.as_str(),
867+
text_field_90_terms_zipf => term_90_b.as_str(),
833868
text_field_1000_terms_zipf => term_1000_a.as_str(),
834869
text_field_1000_terms_zipf => term_1000_b.as_str(),
835870
score_field => 1u64,
@@ -866,6 +901,7 @@ fn get_test_index_bench(cardinality: Cardinality) -> tantivy::Result<Index> {
866901
text_field_many_terms => many_terms_data.choose(&mut rng).unwrap().to_string(),
867902
text_field_few_terms => few_terms_data.choose(&mut rng).unwrap().to_string(),
868903
text_field_few_terms_status => status_field_data[log_level_distribution.sample(&mut rng)].0,
904+
text_field_90_terms_zipf => terms_90[zipf_90.sample(&mut rng) as usize - 1].as_str(),
869905
text_field_1000_terms_zipf => terms_1000[zipf_1000.sample(&mut rng) as usize - 1].as_str(),
870906
score_field => val as u64,
871907
score_field_f64 => lg_norm.sample(&mut rng),

src/aggregation/agg_tests.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,82 @@ fn test_aggregation_flushing_variants() {
664664
test_aggregation_flushing(true, true).unwrap();
665665
}
666666

667+
// Regression test for https://github.com/quickwit-oss/tantivy/issues/2992
668+
//
669+
// A skewed terms bucket over <100 terms uses the low-cardinality (Vec) sub-agg buffer. A dominant
670+
// term keeps crossing the periodic flush threshold (every 2048 docs), which used to drop the
671+
// cached doc ids of the minority buckets before collecting them — corrupting their metric
672+
// sub-aggregations while their doc counts stayed exact.
673+
#[test]
674+
fn test_terms_sub_agg_flushing_skewed_buckets() -> crate::Result<()> {
675+
use std::collections::HashMap;
676+
677+
// 89 minority terms + 1 dominant term = 90 distinct terms, staying below
678+
// MAX_NUM_TERMS_FOR_VEC (100) so the low-cardinality Vec sub-agg buffer is used.
679+
const NUM_MINORITY_TERMS: usize = 89;
680+
681+
let mut values: Vec<(f64, String)> = Vec::new();
682+
let mut minority_idx = 0usize;
683+
// Enough docs to cross the 2048 flush threshold multiple times. The minority docs are
684+
// sprinkled among the dominant ones so they land in different flush windows.
685+
for i in 0..5000u64 {
686+
if i % 25 == 0 {
687+
let term = format!("minority_{:02}", minority_idx % NUM_MINORITY_TERMS);
688+
minority_idx += 1;
689+
values.push(((i % 13 + 1) as f64, term));
690+
} else {
691+
values.push((7.0, "dominant".to_string()));
692+
}
693+
}
694+
695+
let mut truth: HashMap<String, (u64, f64)> = HashMap::new();
696+
for (score, term) in &values {
697+
let entry = truth.entry(term.clone()).or_insert((0, 0.0));
698+
entry.0 += 1;
699+
entry.1 += *score;
700+
}
701+
// Sanity check on the shape of the generated data.
702+
assert_eq!(truth.len(), NUM_MINORITY_TERMS + 1);
703+
704+
let index = get_test_index_from_values_and_terms(false, &[values])?;
705+
let reader = index.reader()?;
706+
707+
let agg_req: Aggregations = serde_json::from_value(json!({
708+
"my_terms": {
709+
"terms": { "field": "string_id", "size": 100 },
710+
"aggs": {
711+
"sum_score": { "sum": { "field": "score" } }
712+
}
713+
}
714+
}))
715+
.unwrap();
716+
717+
let collector = get_collector(agg_req);
718+
let searcher = reader.searcher();
719+
let agg_res = searcher.search(&AllQuery, &collector)?;
720+
let res: Value = serde_json::from_str(&serde_json::to_string(&agg_res)?)?;
721+
722+
let buckets = res["my_terms"]["buckets"].as_array().unwrap();
723+
// size 100 >= 90 distinct terms, so every bucket is returned.
724+
assert_eq!(buckets.len(), truth.len());
725+
for bucket in buckets {
726+
let key = bucket["key"].as_str().unwrap();
727+
let (true_count, true_sum) = truth[key];
728+
assert_eq!(
729+
bucket["doc_count"].as_u64().unwrap(),
730+
true_count,
731+
"doc_count mismatch for {key}"
732+
);
733+
assert_eq!(
734+
bucket["sum_score"]["value"].as_f64().unwrap(),
735+
true_sum,
736+
"sum sub-agg mismatch for {key}"
737+
);
738+
}
739+
740+
Ok(())
741+
}
742+
667743
#[test]
668744
fn test_aggregation_level1_simple() -> crate::Result<()> {
669745
let index = get_test_index_2_segments(true)?;

src/aggregation/buffered_sub_aggs.rs

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ use std::fmt::Debug;
22

33
use super::segment_agg_result::SegmentAggregationCollector;
44
use crate::aggregation::agg_data::AggregationsSegmentCtx;
5-
use crate::aggregation::bucket::MAX_NUM_TERMS_FOR_VEC;
65
use crate::aggregation::BucketId;
76
use crate::DocId;
87

@@ -45,7 +44,6 @@ pub trait SubAggBuffer: Debug {
4544
&mut self,
4645
sub_agg: &mut Box<dyn SegmentAggregationCollector>,
4746
agg_data: &mut AggregationsSegmentCtx,
48-
force: bool,
4947
) -> crate::Result<()>;
5048
}
5149

@@ -76,7 +74,7 @@ impl<Backend: SubAggBuffer + Debug> BufferedSubAggs<Backend> {
7674
) -> crate::Result<()> {
7775
if self.num_docs >= FLUSH_THRESHOLD {
7876
self.buffer
79-
.flush_local(&mut self.sub_agg_collector, agg_data, false)?;
77+
.flush_local(&mut self.sub_agg_collector, agg_data)?;
8078
self.num_docs = 0;
8179
}
8280
Ok(())
@@ -86,7 +84,7 @@ impl<Backend: SubAggBuffer + Debug> BufferedSubAggs<Backend> {
8684
pub fn flush(&mut self, agg_data: &mut AggregationsSegmentCtx) -> crate::Result<()> {
8785
if self.num_docs != 0 {
8886
self.buffer
89-
.flush_local(&mut self.sub_agg_collector, agg_data, true)?;
87+
.flush_local(&mut self.sub_agg_collector, agg_data)?;
9088
self.num_docs = 0;
9189
}
9290
self.sub_agg_collector.flush(agg_data)?;
@@ -150,7 +148,6 @@ impl SubAggBuffer for HighCardSubAggBuffer {
150148
&mut self,
151149
sub_agg: &mut Box<dyn SegmentAggregationCollector>,
152150
agg_data: &mut AggregationsSegmentCtx,
153-
_force: bool,
154151
) -> crate::Result<()> {
155152
let mut max_bucket = 0u32;
156153
for partition in self.partitions.iter() {
@@ -210,34 +207,11 @@ impl SubAggBuffer for LowCardSubAggBuffer {
210207
&mut self,
211208
sub_agg: &mut Box<dyn SegmentAggregationCollector>,
212209
agg_data: &mut AggregationsSegmentCtx,
213-
force: bool,
214210
) -> crate::Result<()> {
215211
// Pre-aggregated: call collect per bucket.
216212
let max_bucket = (self.per_bucket_docs.len() as BucketId).saturating_sub(1);
217213
sub_agg.prepare_max_bucket(max_bucket, agg_data)?;
218-
// The threshold above which we flush buckets individually.
219-
// Note: We need to make sure that we don't lock ourselves into a situation where we hit
220-
// the FLUSH_THRESHOLD, but never flush any buckets. (except the final flush)
221-
let mut bucket_treshold = FLUSH_THRESHOLD / (self.per_bucket_docs.len().max(1) * 2);
222-
const _: () = {
223-
// MAX_NUM_TERMS_FOR_VEC threshold is used for term aggregations
224-
// Note: There may be other flexible values, for other aggregations, but we can use the
225-
// const value here as a upper bound. (better than nothing)
226-
let bucket_treshold_limit = FLUSH_THRESHOLD / (MAX_NUM_TERMS_FOR_VEC as usize * 2);
227-
assert!(
228-
bucket_treshold_limit > 0,
229-
"Bucket threshold must be greater than 0"
230-
);
231-
};
232-
if force {
233-
bucket_treshold = 0;
234-
}
235-
for (bucket_id, docs) in self
236-
.per_bucket_docs
237-
.iter()
238-
.enumerate()
239-
.filter(|(_, docs)| docs.len() > bucket_treshold)
240-
{
214+
for (bucket_id, docs) in self.per_bucket_docs.iter().enumerate() {
241215
sub_agg.collect(bucket_id as BucketId, docs, agg_data)?;
242216
}
243217

0 commit comments

Comments
 (0)