1use std::collections::{BTreeMap, BTreeSet, HashSet};
16use std::iter;
17use std::ops::Range;
18use std::sync::Arc;
19use std::time::Instant;
20
21use common_base::range_read::RangeReader;
22use common_base::term_token::{extract_like_probes, extract_term_probes};
23use common_telemetry::{tracing, warn};
24use index::bloom_filter::applier::{BloomFilterApplier, InListPredicate};
25use index::bloom_filter::reader::{BloomFilterReadMetrics, BloomFilterReaderImpl};
26use index::fulltext_index::Config;
27use index::fulltext_index::search::{FulltextIndexSearcher, RowId, TantivyFulltextIndexSearcher};
28use index::target::IndexTarget;
29use object_store::ObjectStore;
30use puffin::puffin_manager::cache::PuffinMetadataCacheRef;
31use puffin::puffin_manager::{GuardWithMetadata, PuffinManager, PuffinReader};
32use snafu::ResultExt;
33use store_api::region_request::PathType;
34use store_api::storage::ColumnId;
35
36use crate::access_layer::{RegionFilePathFactory, WriteCachePathProvider};
37use crate::cache::file_cache::{FileCacheRef, FileType, IndexKey};
38use crate::cache::index::bloom_filter_index::{
39 BloomFilterIndexCacheRef, CachedBloomFilterIndexBlobReader, Tag,
40};
41use crate::cache::index::result_cache::PredicateKey;
42use crate::error::{
43 ApplyBloomFilterIndexSnafu, ApplyFulltextIndexSnafu, MetadataSnafu, PuffinBuildReaderSnafu,
44 PuffinReadBlobSnafu, Result,
45};
46use crate::metrics::INDEX_APPLY_ELAPSED;
47use crate::sst::file::RegionIndexId;
48use crate::sst::index::fulltext_index::applier::builder::FulltextRequest;
49use crate::sst::index::fulltext_index::{INDEX_BLOB_TYPE_BLOOM, INDEX_BLOB_TYPE_TANTIVY};
50use crate::sst::index::puffin_manager::{
51 PuffinManagerFactory, SstPuffinBlob, SstPuffinDir, SstPuffinReader,
52};
53use crate::sst::index::{TYPE_FULLTEXT_INDEX, trigger_index_background_download};
54
55pub mod builder;
56
57#[derive(Default, Clone)]
59pub struct FulltextIndexApplyMetrics {
60 pub apply_elapsed: std::time::Duration,
62 pub blob_cache_miss: usize,
64 pub dir_cache_hit: usize,
66 pub dir_cache_miss: usize,
68 pub dir_init_elapsed: std::time::Duration,
70 pub bloom_filter_read_metrics: BloomFilterReadMetrics,
72}
73
74impl std::fmt::Debug for FulltextIndexApplyMetrics {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 let Self {
77 apply_elapsed,
78 blob_cache_miss,
79 dir_cache_hit,
80 dir_cache_miss,
81 dir_init_elapsed,
82 bloom_filter_read_metrics,
83 } = self;
84
85 if self.is_empty() {
86 return write!(f, "{{}}");
87 }
88 write!(f, "{{")?;
89
90 write!(f, "\"apply_elapsed\":\"{:?}\"", apply_elapsed)?;
91
92 if *blob_cache_miss > 0 {
93 write!(f, ", \"blob_cache_miss\":{}", blob_cache_miss)?;
94 }
95 if *dir_cache_hit > 0 {
96 write!(f, ", \"dir_cache_hit\":{}", dir_cache_hit)?;
97 }
98 if *dir_cache_miss > 0 {
99 write!(f, ", \"dir_cache_miss\":{}", dir_cache_miss)?;
100 }
101 if !dir_init_elapsed.is_zero() {
102 write!(f, ", \"dir_init_elapsed\":\"{:?}\"", dir_init_elapsed)?;
103 }
104 write!(
105 f,
106 ", \"bloom_filter_read_metrics\":{:?}",
107 bloom_filter_read_metrics
108 )?;
109
110 write!(f, "}}")
111 }
112}
113
114impl FulltextIndexApplyMetrics {
115 pub fn is_empty(&self) -> bool {
117 self.apply_elapsed.is_zero()
118 }
119
120 pub fn collect_dir_metrics(
122 &mut self,
123 elapsed: std::time::Duration,
124 dir_metrics: puffin::puffin_manager::DirMetrics,
125 ) {
126 self.dir_init_elapsed += elapsed;
127 if dir_metrics.cache_hit {
128 self.dir_cache_hit += 1;
129 } else {
130 self.dir_cache_miss += 1;
131 }
132 }
133
134 pub fn merge_from(&mut self, other: &Self) {
136 self.apply_elapsed += other.apply_elapsed;
137 self.blob_cache_miss += other.blob_cache_miss;
138 self.dir_cache_hit += other.dir_cache_hit;
139 self.dir_cache_miss += other.dir_cache_miss;
140 self.dir_init_elapsed += other.dir_init_elapsed;
141 self.bloom_filter_read_metrics
142 .merge_from(&other.bloom_filter_read_metrics);
143 }
144}
145
146pub struct FulltextIndexApplier {
148 requests: Arc<BTreeMap<ColumnId, FulltextRequest>>,
150
151 index_source: IndexSource,
153
154 bloom_filter_index_cache: Option<BloomFilterIndexCacheRef>,
156
157 predicate_key: PredicateKey,
159}
160
161pub type FulltextIndexApplierRef = Arc<FulltextIndexApplier>;
162
163impl FulltextIndexApplier {
164 pub fn new(
166 table_dir: String,
167 path_type: PathType,
168 store: ObjectStore,
169 requests: BTreeMap<ColumnId, FulltextRequest>,
170 puffin_manager_factory: PuffinManagerFactory,
171 ) -> Self {
172 let requests = Arc::new(requests);
173 let index_source = IndexSource::new(table_dir, path_type, puffin_manager_factory, store);
174
175 Self {
176 predicate_key: PredicateKey::new_fulltext(requests.clone()),
177 requests,
178 index_source,
179 bloom_filter_index_cache: None,
180 }
181 }
182
183 pub fn with_file_cache(mut self, file_cache: Option<FileCacheRef>) -> Self {
185 self.index_source.set_file_cache(file_cache);
186 self
187 }
188
189 pub fn with_puffin_metadata_cache(
191 mut self,
192 puffin_metadata_cache: Option<PuffinMetadataCacheRef>,
193 ) -> Self {
194 self.index_source
195 .set_puffin_metadata_cache(puffin_metadata_cache);
196 self
197 }
198
199 pub fn with_bloom_filter_cache(
201 mut self,
202 bloom_filter_index_cache: Option<BloomFilterIndexCacheRef>,
203 ) -> Self {
204 self.bloom_filter_index_cache = bloom_filter_index_cache;
205 self
206 }
207
208 pub fn predicate_key(&self) -> &PredicateKey {
210 &self.predicate_key
211 }
212}
213
214impl FulltextIndexApplier {
215 #[tracing::instrument(
223 skip_all,
224 fields(file_id = %file_id)
225 )]
226 pub async fn apply_fine(
227 &self,
228 file_id: RegionIndexId,
229 file_size_hint: Option<u64>,
230 mut metrics: Option<&mut FulltextIndexApplyMetrics>,
231 ) -> Result<Option<BTreeSet<RowId>>> {
232 let apply_start = Instant::now();
233
234 let mut row_ids: Option<BTreeSet<RowId>> = None;
235 for (column_id, request) in self.requests.iter() {
236 if request.queries.is_empty() {
240 continue;
241 }
242
243 let Some(result) = self
244 .apply_fine_one_column(
245 file_size_hint,
246 file_id,
247 *column_id,
248 request,
249 metrics.as_deref_mut(),
250 )
251 .await?
252 else {
253 continue;
254 };
255
256 if let Some(ids) = row_ids.as_mut() {
257 ids.retain(|id| result.contains(id));
258 } else {
259 row_ids = Some(result);
260 }
261
262 if let Some(ids) = row_ids.as_ref()
263 && ids.is_empty()
264 {
265 break;
266 }
267 }
268
269 let elapsed = apply_start.elapsed();
271 INDEX_APPLY_ELAPSED
272 .with_label_values(&[TYPE_FULLTEXT_INDEX])
273 .observe(elapsed.as_secs_f64());
274
275 if let Some(m) = metrics {
276 m.apply_elapsed += elapsed;
277 }
278
279 Ok(row_ids)
280 }
281
282 async fn apply_fine_one_column(
283 &self,
284 file_size_hint: Option<u64>,
285 file_id: RegionIndexId,
286 column_id: ColumnId,
287 request: &FulltextRequest,
288 metrics: Option<&mut FulltextIndexApplyMetrics>,
289 ) -> Result<Option<BTreeSet<RowId>>> {
290 let blob_key = format!(
291 "{INDEX_BLOB_TYPE_TANTIVY}-{}",
292 IndexTarget::ColumnId(column_id)
293 );
294 let dir = self
295 .index_source
296 .dir(file_id, &blob_key, file_size_hint, metrics)
297 .await?;
298
299 let dir = match &dir {
300 Some(dir) => dir,
301 None => {
302 return Ok(None);
303 }
304 };
305
306 let config = Config::from_blob_metadata(dir.metadata()).context(ApplyFulltextIndexSnafu)?;
307 let path = dir.path();
308
309 let searcher =
310 TantivyFulltextIndexSearcher::new(path, config).context(ApplyFulltextIndexSnafu)?;
311 let mut row_ids: Option<BTreeSet<RowId>> = None;
312
313 for query in &request.queries {
314 let result = searcher
315 .search(&query.0)
316 .await
317 .context(ApplyFulltextIndexSnafu)?;
318
319 if let Some(ids) = row_ids.as_mut() {
320 ids.retain(|id| result.contains(id));
321 } else {
322 row_ids = Some(result);
323 }
324
325 if let Some(ids) = row_ids.as_ref()
326 && ids.is_empty()
327 {
328 break;
329 }
330 }
331
332 Ok(row_ids)
333 }
334}
335
336impl FulltextIndexApplier {
337 #[allow(clippy::type_complexity)]
349 #[tracing::instrument(
350 skip_all,
351 fields(file_id = %file_id)
352 )]
353 pub async fn apply_coarse(
354 &self,
355 file_id: RegionIndexId,
356 file_size_hint: Option<u64>,
357 row_groups: impl Iterator<Item = (usize, bool)>,
358 mut metrics: Option<&mut FulltextIndexApplyMetrics>,
359 ) -> Result<Option<Vec<(usize, Vec<Range<usize>>)>>> {
360 let apply_start = Instant::now();
361
362 let (input, mut output) = Self::init_coarse_output(row_groups);
363 let mut applied = false;
364
365 for (column_id, request) in self.requests.iter() {
366 if request.terms.is_empty() && request.likes.is_empty() {
367 continue;
368 }
369
370 applied |= self
371 .apply_coarse_one_column(
372 file_id,
373 file_size_hint,
374 *column_id,
375 request,
376 &mut output,
377 metrics.as_deref_mut(),
378 )
379 .await?;
380 }
381
382 if !applied {
383 return Ok(None);
384 }
385
386 Self::adjust_coarse_output(input, &mut output);
387
388 let elapsed = apply_start.elapsed();
390 INDEX_APPLY_ELAPSED
391 .with_label_values(&[TYPE_FULLTEXT_INDEX])
392 .observe(elapsed.as_secs_f64());
393
394 if let Some(m) = metrics {
395 m.apply_elapsed += elapsed;
396 }
397
398 Ok(Some(output))
399 }
400
401 async fn apply_coarse_one_column(
402 &self,
403 file_id: RegionIndexId,
404 file_size_hint: Option<u64>,
405 column_id: ColumnId,
406 request: &FulltextRequest,
407 output: &mut [(usize, Vec<Range<usize>>)],
408 mut metrics: Option<&mut FulltextIndexApplyMetrics>,
409 ) -> Result<bool> {
410 let blob_key = format!(
411 "{INDEX_BLOB_TYPE_BLOOM}-{}",
412 IndexTarget::ColumnId(column_id)
413 );
414 let Some(reader) = self
415 .index_source
416 .blob(file_id, &blob_key, file_size_hint, metrics.as_deref_mut())
417 .await?
418 else {
419 return Ok(false);
420 };
421 let config =
422 Config::from_blob_metadata(reader.metadata()).context(ApplyFulltextIndexSnafu)?;
423
424 let predicates = Self::request_to_predicates(request, &config);
425 if predicates.is_empty() {
426 return Ok(false);
427 }
428
429 let range_reader = reader.reader().await.context(PuffinBuildReaderSnafu)?;
430 let reader = if let Some(bloom_filter_cache) = &self.bloom_filter_index_cache {
431 let blob_size = range_reader
432 .metadata()
433 .await
434 .context(MetadataSnafu)?
435 .content_length;
436 let reader = CachedBloomFilterIndexBlobReader::new(
437 file_id.file_id(),
438 file_id.version,
439 column_id,
440 Tag::Fulltext,
441 blob_size,
442 BloomFilterReaderImpl::new(range_reader),
443 bloom_filter_cache.clone(),
444 );
445 Box::new(reader) as _
446 } else {
447 Box::new(BloomFilterReaderImpl::new(range_reader)) as _
448 };
449
450 let mut applier = BloomFilterApplier::new(reader)
451 .await
452 .context(ApplyBloomFilterIndexSnafu)?;
453 let mut row_groups = output.iter_mut().map(|(_, r)| r).collect::<Vec<_>>();
454 applier
455 .search_groups(
456 &predicates,
457 &mut row_groups,
458 metrics.map(|m| &mut m.bloom_filter_read_metrics),
459 )
460 .await
461 .context(ApplyBloomFilterIndexSnafu)?;
462
463 Ok(true)
464 }
465
466 #[allow(clippy::type_complexity)]
474 fn init_coarse_output(
475 row_groups: impl Iterator<Item = (usize, bool)>,
476 ) -> (Vec<(usize, Range<usize>)>, Vec<(usize, Vec<Range<usize>>)>) {
477 let mut input = Vec::with_capacity(row_groups.size_hint().0);
479 let mut start = 0;
480 for (i, (len, to_search)) in row_groups.enumerate() {
481 let end = start + len;
482 if to_search {
483 input.push((i, start..end));
484 }
485 start = end;
486 }
487
488 let output = input
491 .iter()
492 .map(|(i, range)| (*i, vec![range.clone()]))
493 .collect::<Vec<_>>();
494
495 (input, output)
496 }
497
498 fn adjust_coarse_output(
500 input: Vec<(usize, Range<usize>)>,
501 output: &mut [(usize, Vec<Range<usize>>)],
502 ) {
503 for ((_, output), (_, input)) in output.iter_mut().zip(input) {
505 let start = input.start;
506 for range in output.iter_mut() {
507 range.start -= start;
508 range.end -= start;
509 }
510 }
511 }
512
513 fn request_to_predicates(request: &FulltextRequest, config: &Config) -> Vec<InListPredicate> {
516 let probes = |col_lowered: bool, probes: Vec<String>| -> Vec<Vec<u8>> {
519 match (col_lowered, config.case_sensitive) {
520 (true, true) => vec![],
521 (false, false) => probes
522 .iter()
523 .map(|p| p.to_lowercase().into_bytes())
524 .collect(),
525 _ => probes.into_iter().map(String::into_bytes).collect(),
526 }
527 };
528 let term_probes = request
529 .terms
530 .iter()
531 .flat_map(|term| probes(term.col_lowered, extract_term_probes(&term.term)));
532 let like_probes = request
533 .likes
534 .iter()
535 .flat_map(|like| probes(like.col_lowered, extract_like_probes(&like.pattern)));
536
537 term_probes
538 .chain(like_probes)
539 .collect::<HashSet<_>>()
540 .into_iter()
541 .map(|p| InListPredicate {
542 list: iter::once(p).collect(),
543 })
544 .collect::<Vec<_>>()
545 }
546}
547
548struct IndexSource {
550 table_dir: String,
551
552 path_type: PathType,
554
555 puffin_manager_factory: PuffinManagerFactory,
557
558 remote_store: ObjectStore,
560
561 file_cache: Option<FileCacheRef>,
563
564 puffin_metadata_cache: Option<PuffinMetadataCacheRef>,
566}
567
568impl IndexSource {
569 fn new(
570 table_dir: String,
571 path_type: PathType,
572 puffin_manager_factory: PuffinManagerFactory,
573 remote_store: ObjectStore,
574 ) -> Self {
575 Self {
576 table_dir,
577 path_type,
578 puffin_manager_factory,
579 remote_store,
580 file_cache: None,
581 puffin_metadata_cache: None,
582 }
583 }
584
585 fn set_file_cache(&mut self, file_cache: Option<FileCacheRef>) {
586 self.file_cache = file_cache;
587 }
588
589 fn set_puffin_metadata_cache(&mut self, puffin_metadata_cache: Option<PuffinMetadataCacheRef>) {
590 self.puffin_metadata_cache = puffin_metadata_cache;
591 }
592
593 async fn blob(
597 &self,
598 file_id: RegionIndexId,
599 key: &str,
600 file_size_hint: Option<u64>,
601 metrics: Option<&mut FulltextIndexApplyMetrics>,
602 ) -> Result<Option<GuardWithMetadata<SstPuffinBlob>>> {
603 let (reader, fallbacked) = self.ensure_reader(file_id, file_size_hint).await?;
604
605 if fallbacked && let Some(m) = metrics {
607 m.blob_cache_miss += 1;
608 }
609
610 let res = reader.blob(key).await;
611 match res {
612 Ok(blob) => Ok(Some(blob)),
613 Err(err) if err.is_blob_not_found() => Ok(None),
614 Err(err) => {
615 if fallbacked {
616 Err(err).context(PuffinReadBlobSnafu)
617 } else {
618 warn!(err; "An unexpected error occurred while reading the cached index file. Fallback to remote index file.");
619 let reader = self.build_remote(file_id, file_size_hint).await?;
620 let res = reader.blob(key).await;
621 match res {
622 Ok(blob) => Ok(Some(blob)),
623 Err(err) if err.is_blob_not_found() => Ok(None),
624 Err(err) => Err(err).context(PuffinReadBlobSnafu),
625 }
626 }
627 }
628 }
629 }
630
631 async fn dir(
635 &self,
636 file_id: RegionIndexId,
637 key: &str,
638 file_size_hint: Option<u64>,
639 mut metrics: Option<&mut FulltextIndexApplyMetrics>,
640 ) -> Result<Option<GuardWithMetadata<SstPuffinDir>>> {
641 let (reader, fallbacked) = self.ensure_reader(file_id, file_size_hint).await?;
642
643 if fallbacked && let Some(m) = &mut metrics {
645 m.blob_cache_miss += 1;
646 }
647
648 let start = metrics.as_ref().map(|_| Instant::now());
649 let res = reader.dir(key).await;
650 match res {
651 Ok((dir, dir_metrics)) => {
652 if let Some(m) = metrics {
653 m.collect_dir_metrics(start.unwrap().elapsed(), dir_metrics);
655 }
656 Ok(Some(dir))
657 }
658 Err(err) if err.is_blob_not_found() => Ok(None),
659 Err(err) => {
660 if fallbacked {
661 Err(err).context(PuffinReadBlobSnafu)
662 } else {
663 warn!(err; "An unexpected error occurred while reading the cached index file. Fallback to remote index file.");
664 let reader = self.build_remote(file_id, file_size_hint).await?;
665 let start = metrics.as_ref().map(|_| Instant::now());
666 let res = reader.dir(key).await;
667 match res {
668 Ok((dir, dir_metrics)) => {
669 if let Some(m) = metrics {
670 m.collect_dir_metrics(start.unwrap().elapsed(), dir_metrics);
672 }
673 Ok(Some(dir))
674 }
675 Err(err) if err.is_blob_not_found() => Ok(None),
676 Err(err) => Err(err).context(PuffinReadBlobSnafu),
677 }
678 }
679 }
680 }
681 }
682
683 async fn ensure_reader(
685 &self,
686 file_id: RegionIndexId,
687 file_size_hint: Option<u64>,
688 ) -> Result<(SstPuffinReader, bool)> {
689 match self.build_local_cache(file_id, file_size_hint).await {
690 Ok(Some(r)) => Ok((r, false)),
691 Ok(None) => Ok((self.build_remote(file_id, file_size_hint).await?, true)),
692 Err(err) => Err(err),
693 }
694 }
695
696 async fn build_local_cache(
697 &self,
698 file_id: RegionIndexId,
699 file_size_hint: Option<u64>,
700 ) -> Result<Option<SstPuffinReader>> {
701 let Some(file_cache) = &self.file_cache else {
702 return Ok(None);
703 };
704
705 let index_key = IndexKey::new(
706 file_id.region_id(),
707 file_id.file_id(),
708 FileType::Puffin(file_id.version),
709 );
710 if file_cache.get(index_key).await.is_none() {
711 return Ok(None);
712 };
713
714 let puffin_manager = self
715 .puffin_manager_factory
716 .build(
717 file_cache.local_store(),
718 WriteCachePathProvider::new(file_cache.clone()),
719 )
720 .with_puffin_metadata_cache(self.puffin_metadata_cache.clone());
721 let reader = puffin_manager
722 .reader(&file_id)
723 .await
724 .context(PuffinBuildReaderSnafu)?
725 .with_file_size_hint(file_size_hint);
726 Ok(Some(reader))
727 }
728
729 async fn build_remote(
730 &self,
731 file_id: RegionIndexId,
732 file_size_hint: Option<u64>,
733 ) -> Result<SstPuffinReader> {
734 let path_factory = RegionFilePathFactory::new(self.table_dir.clone(), self.path_type);
735
736 trigger_index_background_download(
738 self.file_cache.as_ref(),
739 &file_id,
740 file_size_hint,
741 &path_factory,
742 &self.remote_store,
743 );
744
745 let puffin_manager = self
746 .puffin_manager_factory
747 .build(self.remote_store.clone(), path_factory)
748 .with_puffin_metadata_cache(self.puffin_metadata_cache.clone());
749
750 let reader = puffin_manager
751 .reader(&file_id)
752 .await
753 .context(PuffinBuildReaderSnafu)?
754 .with_file_size_hint(file_size_hint);
755
756 Ok(reader)
757 }
758}
759
760#[cfg(test)]
761mod tests {
762 use common_function::scalars::matches_term::MatchesTermFinder;
763 use datatypes::arrow::array::{Scalar, StringArray};
764 use datatypes::arrow::compute::kernels::comparison::like;
765 use index::fulltext_index::Analyzer;
766 use index::fulltext_index::tokenizer::{self, ScriptTokenizer};
767 use rand::rngs::StdRng;
768 use rand::{Rng, SeedableRng};
769
770 use super::*;
771 use crate::sst::index::fulltext_index::applier::builder::{FulltextLike, FulltextTerm};
772
773 enum Predicate<'a> {
775 Term(&'a str),
776 Like(&'a str),
777 LowerTerm(&'a str),
778 LowerLike(&'a str),
779 }
780
781 impl Predicate<'_> {
782 fn eval(&self, text: &str) -> bool {
783 let like_matches = |text: &str, pattern: &str| {
784 like(
785 &StringArray::from(vec![text]),
786 &Scalar::new(StringArray::from(vec![pattern])),
787 )
788 .unwrap()
789 .value(0)
790 };
791 match self {
792 Predicate::Term(term) => MatchesTermFinder::new(term).find(text),
793 Predicate::Like(pattern) => like_matches(text, pattern),
794 Predicate::LowerTerm(term) => {
795 MatchesTermFinder::new(term).find(&text.to_lowercase())
796 }
797 Predicate::LowerLike(pattern) => like_matches(&text.to_lowercase(), pattern),
798 }
799 }
800
801 fn request(&self) -> FulltextRequest {
802 let mut request = FulltextRequest::default();
803 let (term, like, col_lowered) = match *self {
804 Predicate::Term(term) => (Some(term), None, false),
805 Predicate::Like(pattern) => (None, Some(pattern), false),
806 Predicate::LowerTerm(term) => (Some(term), None, true),
807 Predicate::LowerLike(pattern) => (None, Some(pattern), true),
808 };
809 request.terms.extend(term.map(|term| FulltextTerm {
810 col_lowered,
811 term: term.to_string(),
812 }));
813 request.likes.extend(like.map(|pattern| FulltextLike {
814 col_lowered,
815 pattern: pattern.to_string(),
816 }));
817 request
818 }
819
820 fn assert_probes_indexed(&self, text: &str) -> usize {
823 let mut num_probes = 0;
824 for case_sensitive in [true, false] {
825 let config = Config {
826 analyzer: Analyzer::English,
827 case_sensitive,
828 };
829 let tokens = tokenizer::Analyzer::new(Box::new(ScriptTokenizer), case_sensitive)
830 .analyze_text(text)
831 .unwrap()
832 .into_iter()
833 .collect::<HashSet<_>>();
834 let predicates =
835 FulltextIndexApplier::request_to_predicates(&self.request(), &config);
836 for predicate in &predicates {
837 assert!(
838 predicate.list.iter().any(|probe| tokens.contains(probe)),
839 "probe {:?} not indexed, text: {text:?}, case_sensitive: {case_sensitive}",
840 predicate
841 .list
842 .iter()
843 .map(|p| String::from_utf8_lossy(p))
844 .collect::<Vec<_>>(),
845 );
846 }
847 num_probes = predicates.len();
848 }
849 num_probes
850 }
851 }
852
853 #[test]
854 fn test_probes_keep_matching_rows() {
855 let cases = [
857 ("hello_world", Predicate::Term("world"), true),
858 ("trace_id=abc", Predicate::Term("id"), true),
859 ("错误error日志", Predicate::Term("error"), true),
860 (
861 "登录手机号18888888888的动态key",
862 Predicate::Term("手机号"),
863 true,
864 ),
865 (
866 "登录手机号18888888888的动态key",
867 Predicate::Term("机号"),
868 true,
869 ),
870 (
871 "登录手机号18888888888的动态key",
872 Predicate::Term("18888888888"),
873 true,
874 ),
875 (
877 "登录手机号18888888888的动态key",
878 Predicate::Term("机号1888"),
879 true,
880 ),
881 ("中国农业银行", Predicate::Term("农业"), true),
882 ("中国农业银行", Predicate::Term("农"), false),
883 ("连接timeout.5次", Predicate::Term("timeout"), true),
884 ("用户user-123登录", Predicate::Term("user"), true),
885 (
886 "CRITICAL error: disk",
887 Predicate::Term("CRITICAL error"),
888 true,
889 ),
890 ("café>", Predicate::Term("café"), true),
891 (
892 "connection timeout after 5s",
893 Predicate::Like("%timeout%"),
894 false,
895 ),
896 (
897 "connection timeout after 5s",
898 Predicate::Like("% timeout %"),
899 true,
900 ),
901 (
902 "connection timeout after 5s",
903 Predicate::Like("connection%"),
904 false,
905 ),
906 (
907 "connection timeout after 5s",
908 Predicate::Like("connection %"),
909 true,
910 ),
911 (
912 "connection timeout after 5s",
913 Predicate::Like("%after 5s"),
914 true,
915 ),
916 ("trace_id=abc", Predicate::Like(r"trace\_id=%"), true),
917 ("trace_id=abc", Predicate::Like("trace_id=%"), false),
918 ("100% done", Predicate::Like(r"100\% done"), true),
919 ("错误error日志", Predicate::Like("%错误error日%"), true),
920 ("错误error日志", Predicate::Like("%r日志"), true),
921 ];
922 let lowered_cases = [
923 (
924 "Connection TIMEOUT after 5s",
925 Predicate::LowerTerm("timeout"),
926 true,
927 ),
928 (
929 "Connection TIMEOUT after 5s",
930 Predicate::LowerLike("% timeout %"),
931 true,
932 ),
933 ("a\u{212A}b", Predicate::LowerTerm("akb"), true),
936 ("\u{130}stanbul", Predicate::LowerTerm("stanbul"), true),
937 ("ΟΔΟΣ.x", Predicate::LowerTerm("οδοσ"), true),
938 ];
939 for (text, predicate, has_probes) in cases.into_iter().chain(lowered_cases) {
940 assert!(predicate.eval(text), "case doesn't match: {text:?}");
941 assert_eq!(
942 predicate.assert_probes_indexed(text) > 0,
943 has_probes,
944 "text: {text:?}"
945 );
946 }
947 }
948
949 #[test]
950 fn test_probes_keep_matching_rows_random() {
951 const ALPHABET: &[char] = &[
954 'a', 'b', 'A', '1', '_', '-', ' ', '.', '%', '\\', '错', '误', 'é', 'Д', '\u{212A}',
955 '\u{130}', 'Σ',
956 ];
957 let mut rng = StdRng::seed_from_u64(42);
958 let random_char = |rng: &mut StdRng| ALPHABET[rng.random_range(0..ALPHABET.len())];
959
960 for _ in 0..20000 {
961 let text = (0..rng.random_range(0..12))
962 .map(|_| random_char(&mut rng))
963 .collect::<String>();
964 let chars = text.chars().collect::<Vec<_>>();
965 let start = rng.random_range(0..=chars.len());
966 let end = rng.random_range(start..=chars.len());
967
968 let term = chars[start..end].iter().collect::<String>();
969 let lowered = term.to_lowercase();
970 for predicate in [Predicate::Term(&term), Predicate::LowerTerm(&lowered)] {
971 if predicate.eval(&text) {
972 predicate.assert_probes_indexed(&text);
973 }
974 }
975
976 let mut pattern = String::new();
977 if start > 0 && rng.random_bool(0.5) {
978 pattern.push('%');
979 }
980 for &c in &chars[start..end] {
981 match rng.random_range(0..10) {
982 0 => pattern.push('%'),
983 1 => pattern.push('_'),
984 _ if matches!(c, '%' | '_' | '\\') => {
985 pattern.push('\\');
986 pattern.push(c);
987 }
988 _ => pattern.push(c),
989 }
990 }
991 if end < chars.len() && rng.random_bool(0.5) {
992 pattern.push('%');
993 }
994 let lowered = pattern.to_lowercase();
995 for predicate in [Predicate::Like(&pattern), Predicate::LowerLike(&lowered)] {
996 if predicate.eval(&text) {
997 predicate.assert_probes_indexed(&text);
998 }
999 }
1000 }
1001 }
1002}