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 datatypes::schema::{FulltextBackend, FulltextOptions};
25use index::bloom_filter::applier::{BloomFilterApplier, InListPredicate};
26use index::bloom_filter::reader::{BloomFilterReadMetrics, BloomFilterReaderImpl};
27use index::fulltext_index::Config;
28use index::fulltext_index::search::{FulltextIndexSearcher, RowId, TantivyFulltextIndexSearcher};
29use index::target::IndexTarget;
30use object_store::ObjectStore;
31use puffin::puffin_manager::cache::PuffinMetadataCacheRef;
32use puffin::puffin_manager::{GuardWithMetadata, PuffinManager, PuffinReader};
33use snafu::ResultExt;
34use store_api::metadata::RegionMetadata;
35use store_api::region_request::PathType;
36
37use crate::access_layer::{RegionFilePathFactory, WriteCachePathProvider};
38use crate::cache::file_cache::{FileCacheRef, FileType, IndexKey};
39use crate::cache::index::bloom_filter_index::{
40 BloomFilterIndexCacheRef, CachedBloomFilterIndexBlobReader, Tag,
41};
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::column::fulltext_target_options;
49use crate::sst::index::fulltext_index::applier::builder::FulltextRequest;
50use crate::sst::index::fulltext_index::{INDEX_BLOB_TYPE_BLOOM, INDEX_BLOB_TYPE_TANTIVY};
51use crate::sst::index::puffin_manager::{
52 PuffinManagerFactory, SstPuffinBlob, SstPuffinDir, SstPuffinReader,
53};
54use crate::sst::index::{TYPE_FULLTEXT_INDEX, trigger_index_background_download};
55
56pub mod builder;
57
58#[derive(Default, Clone)]
60pub struct FulltextIndexApplyMetrics {
61 pub apply_elapsed: std::time::Duration,
63 pub blob_cache_miss: usize,
65 pub dir_cache_hit: usize,
67 pub dir_cache_miss: usize,
69 pub dir_init_elapsed: std::time::Duration,
71 pub bloom_filter_read_metrics: BloomFilterReadMetrics,
73}
74
75impl std::fmt::Debug for FulltextIndexApplyMetrics {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 let Self {
78 apply_elapsed,
79 blob_cache_miss,
80 dir_cache_hit,
81 dir_cache_miss,
82 dir_init_elapsed,
83 bloom_filter_read_metrics,
84 } = self;
85
86 if self.is_empty() {
87 return write!(f, "{{}}");
88 }
89 write!(f, "{{")?;
90
91 write!(f, "\"apply_elapsed\":\"{:?}\"", apply_elapsed)?;
92
93 if *blob_cache_miss > 0 {
94 write!(f, ", \"blob_cache_miss\":{}", blob_cache_miss)?;
95 }
96 if *dir_cache_hit > 0 {
97 write!(f, ", \"dir_cache_hit\":{}", dir_cache_hit)?;
98 }
99 if *dir_cache_miss > 0 {
100 write!(f, ", \"dir_cache_miss\":{}", dir_cache_miss)?;
101 }
102 if !dir_init_elapsed.is_zero() {
103 write!(f, ", \"dir_init_elapsed\":\"{:?}\"", dir_init_elapsed)?;
104 }
105 write!(
106 f,
107 ", \"bloom_filter_read_metrics\":{:?}",
108 bloom_filter_read_metrics
109 )?;
110
111 write!(f, "}}")
112 }
113}
114
115impl FulltextIndexApplyMetrics {
116 pub fn is_empty(&self) -> bool {
118 self.apply_elapsed.is_zero()
119 }
120
121 pub fn collect_dir_metrics(
123 &mut self,
124 elapsed: std::time::Duration,
125 dir_metrics: puffin::puffin_manager::DirMetrics,
126 ) {
127 self.dir_init_elapsed += elapsed;
128 if dir_metrics.cache_hit {
129 self.dir_cache_hit += 1;
130 } else {
131 self.dir_cache_miss += 1;
132 }
133 }
134
135 pub fn merge_from(&mut self, other: &Self) {
137 self.apply_elapsed += other.apply_elapsed;
138 self.blob_cache_miss += other.blob_cache_miss;
139 self.dir_cache_hit += other.dir_cache_hit;
140 self.dir_cache_miss += other.dir_cache_miss;
141 self.dir_init_elapsed += other.dir_init_elapsed;
142 self.bloom_filter_read_metrics
143 .merge_from(&other.bloom_filter_read_metrics);
144 }
145}
146
147pub struct FulltextIndexApplier {
149 requests: Arc<BTreeMap<IndexTarget, FulltextRequest>>,
151
152 expected_options: BTreeMap<IndexTarget, FulltextOptions>,
154
155 index_source: IndexSource,
157
158 bloom_filter_index_cache: Option<BloomFilterIndexCacheRef>,
160}
161
162pub type FulltextIndexApplierRef = Arc<FulltextIndexApplier>;
163
164impl FulltextIndexApplier {
165 pub fn new(
167 table_dir: String,
168 path_type: PathType,
169 store: ObjectStore,
170 requests: BTreeMap<IndexTarget, FulltextRequest>,
171 puffin_manager_factory: PuffinManagerFactory,
172 metadata: &RegionMetadata,
173 ) -> Self {
174 let expected_options = requests
175 .keys()
176 .filter_map(|target| {
177 fulltext_target_options(metadata, target)
178 .inspect_err(|err| warn!(err; "Failed to resolve expected fulltext options for {target:?}"))
179 .ok()
180 .flatten()
181 .map(|options| (target.clone(), options))
182 })
183 .collect();
184 let requests = Arc::new(requests);
185 let index_source = IndexSource::new(table_dir, path_type, puffin_manager_factory, store);
186
187 Self {
188 requests,
189 expected_options,
190 index_source,
191 bloom_filter_index_cache: None,
192 }
193 }
194
195 pub fn compatible_requests_for_sst(
198 &self,
199 metadata: &RegionMetadata,
200 ) -> Option<Arc<BTreeMap<IndexTarget, FulltextRequest>>> {
201 if self
202 .requests
203 .keys()
204 .all(|target| self.is_target_compatible(metadata, target))
205 {
206 return Some(self.requests.clone());
207 }
208 let requests: BTreeMap<_, _> = self
209 .requests
210 .iter()
211 .filter(|(target, _)| self.is_target_compatible(metadata, target))
212 .map(|(target, request)| (target.clone(), request.clone()))
213 .collect();
214 if requests.is_empty() {
215 return None;
216 }
217 Some(Arc::new(requests))
218 }
219
220 pub fn with_file_cache(mut self, file_cache: Option<FileCacheRef>) -> Self {
222 self.index_source.set_file_cache(file_cache);
223 self
224 }
225
226 pub fn with_puffin_metadata_cache(
228 mut self,
229 puffin_metadata_cache: Option<PuffinMetadataCacheRef>,
230 ) -> Self {
231 self.index_source
232 .set_puffin_metadata_cache(puffin_metadata_cache);
233 self
234 }
235
236 pub fn with_bloom_filter_cache(
238 mut self,
239 bloom_filter_index_cache: Option<BloomFilterIndexCacheRef>,
240 ) -> Self {
241 self.bloom_filter_index_cache = bloom_filter_index_cache;
242 self
243 }
244
245 fn is_target_compatible(&self, metadata: &RegionMetadata, target: &IndexTarget) -> bool {
246 let Some(expected) = self.expected_options.get(target) else {
247 return false;
248 };
249 if let IndexTarget::ColumnId(id) = target {
250 return metadata
253 .column_by_id(*id)
254 .is_some_and(|column| column.column_schema.data_type.is_string());
255 }
256 let Some(actual) = fulltext_target_options(metadata, target)
257 .inspect_err(|err| warn!(err; "Failed to resolve SST fulltext options for {target:?}"))
258 .ok()
259 .flatten()
260 else {
261 return false;
262 };
263 expected.backend == actual.backend
265 && expected.case_sensitive == actual.case_sensitive
266 && (expected.backend == FulltextBackend::Bloom || expected.analyzer == actual.analyzer)
267 }
268}
269
270impl FulltextIndexApplier {
271 #[tracing::instrument(
280 skip_all,
281 fields(file_id = %file_id)
282 )]
283 pub async fn apply_fine(
284 &self,
285 file_id: RegionIndexId,
286 file_size_hint: Option<u64>,
287 requests: &BTreeMap<IndexTarget, FulltextRequest>,
288 mut metrics: Option<&mut FulltextIndexApplyMetrics>,
289 ) -> Result<Option<BTreeSet<RowId>>> {
290 let apply_start = Instant::now();
291
292 let mut row_ids: Option<BTreeSet<RowId>> = None;
293 for (target, request) in requests.iter() {
294 if request.queries.is_empty() {
298 continue;
299 }
300
301 let Some(result) = self
302 .apply_fine_one_column(
303 file_size_hint,
304 file_id,
305 target,
306 request,
307 metrics.as_deref_mut(),
308 )
309 .await?
310 else {
311 continue;
312 };
313
314 if let Some(ids) = row_ids.as_mut() {
315 ids.retain(|id| result.contains(id));
316 } else {
317 row_ids = Some(result);
318 }
319
320 if let Some(ids) = row_ids.as_ref()
321 && ids.is_empty()
322 {
323 break;
324 }
325 }
326
327 let elapsed = apply_start.elapsed();
329 INDEX_APPLY_ELAPSED
330 .with_label_values(&[TYPE_FULLTEXT_INDEX])
331 .observe(elapsed.as_secs_f64());
332
333 if let Some(m) = metrics {
334 m.apply_elapsed += elapsed;
335 }
336
337 Ok(row_ids)
338 }
339
340 async fn apply_fine_one_column(
341 &self,
342 file_size_hint: Option<u64>,
343 file_id: RegionIndexId,
344 target: &IndexTarget,
345 request: &FulltextRequest,
346 metrics: Option<&mut FulltextIndexApplyMetrics>,
347 ) -> Result<Option<BTreeSet<RowId>>> {
348 let target_key = target.encode();
349 let blob_key = format!("{INDEX_BLOB_TYPE_TANTIVY}-{target_key}");
350 let dir = self
351 .index_source
352 .dir(file_id, &blob_key, file_size_hint, metrics)
353 .await?;
354
355 let dir = match &dir {
356 Some(dir) => dir,
357 None => {
358 return Ok(None);
359 }
360 };
361
362 let config = Config::from_blob_metadata(dir.metadata()).context(ApplyFulltextIndexSnafu)?;
363 let path = dir.path();
364
365 let searcher =
366 TantivyFulltextIndexSearcher::new(path, config).context(ApplyFulltextIndexSnafu)?;
367 let mut row_ids: Option<BTreeSet<RowId>> = None;
368
369 for query in &request.queries {
370 let result = searcher
371 .search(&query.0)
372 .await
373 .context(ApplyFulltextIndexSnafu)?;
374
375 if let Some(ids) = row_ids.as_mut() {
376 ids.retain(|id| result.contains(id));
377 } else {
378 row_ids = Some(result);
379 }
380
381 if let Some(ids) = row_ids.as_ref()
382 && ids.is_empty()
383 {
384 break;
385 }
386 }
387
388 Ok(row_ids)
389 }
390}
391
392impl FulltextIndexApplier {
393 #[allow(clippy::type_complexity)]
406 #[tracing::instrument(
407 skip_all,
408 fields(file_id = %file_id)
409 )]
410 pub async fn apply_coarse(
411 &self,
412 file_id: RegionIndexId,
413 file_size_hint: Option<u64>,
414 row_groups: impl Iterator<Item = (usize, bool)>,
415 requests: &BTreeMap<IndexTarget, FulltextRequest>,
416 mut metrics: Option<&mut FulltextIndexApplyMetrics>,
417 ) -> Result<Option<Vec<(usize, Vec<Range<usize>>)>>> {
418 let apply_start = Instant::now();
419
420 let (input, mut output) = Self::init_coarse_output(row_groups);
421 let mut applied = false;
422
423 for (target, request) in requests.iter() {
424 if request.terms.is_empty() && request.likes.is_empty() {
425 continue;
426 }
427
428 applied |= self
429 .apply_coarse_one_column(
430 file_id,
431 file_size_hint,
432 target,
433 request,
434 &mut output,
435 metrics.as_deref_mut(),
436 )
437 .await?;
438 }
439
440 if !applied {
441 return Ok(None);
442 }
443
444 Self::adjust_coarse_output(input, &mut output);
445
446 let elapsed = apply_start.elapsed();
448 INDEX_APPLY_ELAPSED
449 .with_label_values(&[TYPE_FULLTEXT_INDEX])
450 .observe(elapsed.as_secs_f64());
451
452 if let Some(m) = metrics {
453 m.apply_elapsed += elapsed;
454 }
455
456 Ok(Some(output))
457 }
458
459 async fn apply_coarse_one_column(
460 &self,
461 file_id: RegionIndexId,
462 file_size_hint: Option<u64>,
463 target: &IndexTarget,
464 request: &FulltextRequest,
465 output: &mut [(usize, Vec<Range<usize>>)],
466 mut metrics: Option<&mut FulltextIndexApplyMetrics>,
467 ) -> Result<bool> {
468 let target_key = target.encode();
469 let blob_key = format!("{INDEX_BLOB_TYPE_BLOOM}-{target_key}");
470 let Some(reader) = self
471 .index_source
472 .blob(file_id, &blob_key, file_size_hint, metrics.as_deref_mut())
473 .await?
474 else {
475 return Ok(false);
476 };
477 let config =
478 Config::from_blob_metadata(reader.metadata()).context(ApplyFulltextIndexSnafu)?;
479
480 let predicates = Self::request_to_predicates(request, &config);
481 if predicates.is_empty() {
482 return Ok(false);
483 }
484
485 let range_reader = reader.reader().await.context(PuffinBuildReaderSnafu)?;
486 let reader = if let Some(bloom_filter_cache) = &self.bloom_filter_index_cache {
487 let blob_size = range_reader
488 .metadata()
489 .await
490 .context(MetadataSnafu)?
491 .content_length;
492 let reader = CachedBloomFilterIndexBlobReader::new(
493 file_id.file_id(),
494 file_id.version,
495 target.clone(),
496 Tag::Fulltext,
497 blob_size,
498 BloomFilterReaderImpl::new(range_reader),
499 bloom_filter_cache.clone(),
500 );
501 Box::new(reader) as _
502 } else {
503 Box::new(BloomFilterReaderImpl::new(range_reader)) as _
504 };
505
506 let mut applier = BloomFilterApplier::new(reader)
507 .await
508 .context(ApplyBloomFilterIndexSnafu)?;
509 let mut row_groups = output.iter_mut().map(|(_, r)| r).collect::<Vec<_>>();
510 applier
511 .search_groups(
512 &predicates,
513 &mut row_groups,
514 metrics.map(|m| &mut m.bloom_filter_read_metrics),
515 )
516 .await
517 .context(ApplyBloomFilterIndexSnafu)?;
518
519 Ok(true)
520 }
521
522 #[allow(clippy::type_complexity)]
530 fn init_coarse_output(
531 row_groups: impl Iterator<Item = (usize, bool)>,
532 ) -> (Vec<(usize, Range<usize>)>, Vec<(usize, Vec<Range<usize>>)>) {
533 let mut input = Vec::with_capacity(row_groups.size_hint().0);
535 let mut start = 0;
536 for (i, (len, to_search)) in row_groups.enumerate() {
537 let end = start + len;
538 if to_search {
539 input.push((i, start..end));
540 }
541 start = end;
542 }
543
544 let output = input
547 .iter()
548 .map(|(i, range)| (*i, vec![range.clone()]))
549 .collect::<Vec<_>>();
550
551 (input, output)
552 }
553
554 fn adjust_coarse_output(
556 input: Vec<(usize, Range<usize>)>,
557 output: &mut [(usize, Vec<Range<usize>>)],
558 ) {
559 for ((_, output), (_, input)) in output.iter_mut().zip(input) {
561 let start = input.start;
562 for range in output.iter_mut() {
563 range.start -= start;
564 range.end -= start;
565 }
566 }
567 }
568
569 fn request_to_predicates(request: &FulltextRequest, config: &Config) -> Vec<InListPredicate> {
572 let probes = |col_lowered: bool, probes: Vec<String>| -> Vec<Vec<u8>> {
575 match (col_lowered, config.case_sensitive) {
576 (true, true) => vec![],
577 (false, false) => probes
578 .iter()
579 .map(|p| p.to_lowercase().into_bytes())
580 .collect(),
581 _ => probes.into_iter().map(String::into_bytes).collect(),
582 }
583 };
584 let term_probes = request
585 .terms
586 .iter()
587 .flat_map(|term| probes(term.col_lowered, extract_term_probes(&term.term)));
588 let like_probes = request
589 .likes
590 .iter()
591 .flat_map(|like| probes(like.col_lowered, extract_like_probes(&like.pattern)));
592
593 term_probes
594 .chain(like_probes)
595 .collect::<HashSet<_>>()
596 .into_iter()
597 .map(|p| InListPredicate {
598 list: iter::once(p).collect(),
599 })
600 .collect::<Vec<_>>()
601 }
602}
603
604#[derive(Clone)]
606struct IndexSource {
607 table_dir: String,
608
609 path_type: PathType,
611
612 puffin_manager_factory: PuffinManagerFactory,
614
615 remote_store: ObjectStore,
617
618 file_cache: Option<FileCacheRef>,
620
621 puffin_metadata_cache: Option<PuffinMetadataCacheRef>,
623}
624
625impl IndexSource {
626 fn new(
627 table_dir: String,
628 path_type: PathType,
629 puffin_manager_factory: PuffinManagerFactory,
630 remote_store: ObjectStore,
631 ) -> Self {
632 Self {
633 table_dir,
634 path_type,
635 puffin_manager_factory,
636 remote_store,
637 file_cache: None,
638 puffin_metadata_cache: None,
639 }
640 }
641
642 fn set_file_cache(&mut self, file_cache: Option<FileCacheRef>) {
643 self.file_cache = file_cache;
644 }
645
646 fn set_puffin_metadata_cache(&mut self, puffin_metadata_cache: Option<PuffinMetadataCacheRef>) {
647 self.puffin_metadata_cache = puffin_metadata_cache;
648 }
649
650 async fn blob(
654 &self,
655 file_id: RegionIndexId,
656 key: &str,
657 file_size_hint: Option<u64>,
658 metrics: Option<&mut FulltextIndexApplyMetrics>,
659 ) -> Result<Option<GuardWithMetadata<SstPuffinBlob>>> {
660 let (reader, fallbacked) = self.ensure_reader(file_id, file_size_hint).await?;
661
662 if fallbacked && let Some(m) = metrics {
664 m.blob_cache_miss += 1;
665 }
666
667 let res = reader.blob(key).await;
668 match res {
669 Ok(blob) => Ok(Some(blob)),
670 Err(err) if err.is_blob_not_found() => Ok(None),
671 Err(err) => {
672 if fallbacked {
673 Err(err).context(PuffinReadBlobSnafu)
674 } else {
675 warn!(err; "An unexpected error occurred while reading the cached index file. Fallback to remote index file.");
676 let reader = self.build_remote(file_id, file_size_hint).await?;
677 let res = reader.blob(key).await;
678 match res {
679 Ok(blob) => Ok(Some(blob)),
680 Err(err) if err.is_blob_not_found() => Ok(None),
681 Err(err) => Err(err).context(PuffinReadBlobSnafu),
682 }
683 }
684 }
685 }
686 }
687
688 async fn dir(
692 &self,
693 file_id: RegionIndexId,
694 key: &str,
695 file_size_hint: Option<u64>,
696 mut metrics: Option<&mut FulltextIndexApplyMetrics>,
697 ) -> Result<Option<GuardWithMetadata<SstPuffinDir>>> {
698 let (reader, fallbacked) = self.ensure_reader(file_id, file_size_hint).await?;
699
700 if fallbacked && let Some(m) = &mut metrics {
702 m.blob_cache_miss += 1;
703 }
704
705 let start = metrics.as_ref().map(|_| Instant::now());
706 let res = reader.dir(key).await;
707 match res {
708 Ok((dir, dir_metrics)) => {
709 if let Some(m) = metrics {
710 m.collect_dir_metrics(start.unwrap().elapsed(), dir_metrics);
712 }
713 Ok(Some(dir))
714 }
715 Err(err) if err.is_blob_not_found() => Ok(None),
716 Err(err) => {
717 if fallbacked {
718 Err(err).context(PuffinReadBlobSnafu)
719 } else {
720 warn!(err; "An unexpected error occurred while reading the cached index file. Fallback to remote index file.");
721 let reader = self.build_remote(file_id, file_size_hint).await?;
722 let start = metrics.as_ref().map(|_| Instant::now());
723 let res = reader.dir(key).await;
724 match res {
725 Ok((dir, dir_metrics)) => {
726 if let Some(m) = metrics {
727 m.collect_dir_metrics(start.unwrap().elapsed(), dir_metrics);
729 }
730 Ok(Some(dir))
731 }
732 Err(err) if err.is_blob_not_found() => Ok(None),
733 Err(err) => Err(err).context(PuffinReadBlobSnafu),
734 }
735 }
736 }
737 }
738 }
739
740 async fn ensure_reader(
742 &self,
743 file_id: RegionIndexId,
744 file_size_hint: Option<u64>,
745 ) -> Result<(SstPuffinReader, bool)> {
746 match self.build_local_cache(file_id, file_size_hint).await {
747 Ok(Some(r)) => Ok((r, false)),
748 Ok(None) => Ok((self.build_remote(file_id, file_size_hint).await?, true)),
749 Err(err) => Err(err),
750 }
751 }
752
753 async fn build_local_cache(
754 &self,
755 file_id: RegionIndexId,
756 file_size_hint: Option<u64>,
757 ) -> Result<Option<SstPuffinReader>> {
758 let Some(file_cache) = &self.file_cache else {
759 return Ok(None);
760 };
761
762 let index_key = IndexKey::new(
763 file_id.region_id(),
764 file_id.file_id(),
765 FileType::Puffin(file_id.version),
766 );
767 if file_cache.get(index_key).await.is_none() {
768 return Ok(None);
769 };
770
771 let puffin_manager = self
772 .puffin_manager_factory
773 .build(
774 file_cache.local_store(),
775 WriteCachePathProvider::new(file_cache.clone()),
776 )
777 .with_puffin_metadata_cache(self.puffin_metadata_cache.clone());
778 let reader = puffin_manager
779 .reader(&file_id)
780 .await
781 .context(PuffinBuildReaderSnafu)?
782 .with_file_size_hint(file_size_hint);
783 Ok(Some(reader))
784 }
785
786 async fn build_remote(
787 &self,
788 file_id: RegionIndexId,
789 file_size_hint: Option<u64>,
790 ) -> Result<SstPuffinReader> {
791 let path_factory = RegionFilePathFactory::new(self.table_dir.clone(), self.path_type);
792
793 trigger_index_background_download(
795 self.file_cache.as_ref(),
796 &file_id,
797 file_size_hint,
798 &path_factory,
799 &self.remote_store,
800 );
801
802 let puffin_manager = self
803 .puffin_manager_factory
804 .build(self.remote_store.clone(), path_factory)
805 .with_puffin_metadata_cache(self.puffin_metadata_cache.clone());
806
807 let reader = puffin_manager
808 .reader(&file_id)
809 .await
810 .context(PuffinBuildReaderSnafu)?
811 .with_file_size_hint(file_size_hint);
812
813 Ok(reader)
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use api::v1::SemanticType;
820 use common_function::scalars::matches_term::MatchesTermFinder;
821 use datatypes::arrow::array::{Scalar, StringArray};
822 use datatypes::arrow::compute::kernels::comparison::like;
823 use datatypes::data_type::ConcreteDataType;
824 use datatypes::extension::json::{Json2ExtensionType, JsonMetadata};
825 use datatypes::json::{JsonSettings, JsonTypeHint};
826 use datatypes::schema::{ColumnSchema, FulltextAnalyzer};
827 use index::fulltext_index::Analyzer;
828 use index::fulltext_index::tokenizer::{self, ScriptTokenizer};
829 use rand::rngs::StdRng;
830 use rand::{Rng, SeedableRng};
831 use store_api::metadata::{ColumnMetadata, RegionMetadataBuilder};
832 use store_api::storage::RegionId;
833
834 use super::*;
835 use crate::cache::index::result_cache::PredicateKey;
836 use crate::sst::index::fulltext_index::applier::builder::{FulltextLike, FulltextTerm};
837
838 #[test]
839 fn test_json_fulltext_sst_compatibility() {
840 let (_dir, factory) = PuffinManagerFactory::new_for_test_block("json_fulltext_plan");
841 let ordinary = IndexTarget::ColumnId(1);
842 let path = IndexTarget::new_json_path(3, vec!["message".into()]).unwrap();
843 let other = IndexTarget::new_json_path(3, vec!["other".into()]).unwrap();
844 let missing = IndexTarget::new_json_path(3, vec!["missing".into()]).unwrap();
845 for backend in [FulltextBackend::Bloom, FulltextBackend::Tantivy] {
846 let options = FulltextOptions {
847 enable: true,
848 backend: backend.clone(),
849 ..Default::default()
850 };
851 let metadata = json_metadata(Some(options.clone()));
852 let requests = BTreeMap::from([
853 (ordinary.clone(), FulltextRequest::default()),
854 (path.clone(), FulltextRequest::default()),
855 (other.clone(), FulltextRequest::default()),
856 ]);
857 let applier = FulltextIndexApplier::new(
858 "test".into(),
859 PathType::Bare,
860 object_store::ObjectStore::new(object_store::services::Memory::default()).unwrap(),
861 requests,
862 factory.clone(),
863 &metadata,
864 );
865 let unchanged = applier.compatible_requests_for_sst(&metadata).unwrap();
866 assert!(Arc::ptr_eq(&unchanged, &applier.requests));
867 for name in [
868 "granularity",
869 "false_positive_rate",
870 "analyzer",
871 "case_sensitive",
872 "backend",
873 "disabled",
874 "missing",
875 ] {
876 let mut changed = options.clone();
877 match name {
878 "granularity" => changed.granularity += 1,
879 "false_positive_rate" => changed.false_positive_rate_in_10000 += 1,
880 "analyzer" => changed.analyzer = FulltextAnalyzer::Chinese,
881 "case_sensitive" => changed.case_sensitive = true,
882 "backend" => {
883 changed.backend = if backend == FulltextBackend::Bloom {
884 FulltextBackend::Tantivy
885 } else {
886 FulltextBackend::Bloom
887 };
888 }
889 "disabled" => changed.enable = false,
890 "missing" => {}
891 _ => unreachable!(),
892 }
893 let compatible = matches!(name, "granularity" | "false_positive_rate")
894 || (name == "analyzer" && backend == FulltextBackend::Bloom);
895 let sst = json_metadata((name != "missing").then_some(changed));
896 let requests = applier.compatible_requests_for_sst(&sst).unwrap();
897 assert!(requests.contains_key(&ordinary));
898 assert!(requests.contains_key(&other));
899 assert_eq!(
900 requests.contains_key(&path),
901 compatible,
902 "{backend:?}, {name}"
903 );
904 assert_eq!(Arc::ptr_eq(&requests, &applier.requests), compatible);
905 assert_eq!(
906 PredicateKey::new_fulltext(requests)
907 == PredicateKey::new_fulltext(applier.requests.clone()),
908 compatible,
909 );
910 }
911 let applier = FulltextIndexApplier::new(
914 "test".into(),
915 PathType::Bare,
916 object_store::ObjectStore::new(object_store::services::Memory::default()).unwrap(),
917 BTreeMap::from([(missing.clone(), FulltextRequest::default())]),
918 factory.clone(),
919 &metadata,
920 );
921 assert!(applier.compatible_requests_for_sst(&metadata).is_none());
922 let applier = FulltextIndexApplier::new(
924 "test".into(),
925 PathType::Bare,
926 object_store::ObjectStore::new(object_store::services::Memory::default()).unwrap(),
927 BTreeMap::from([(path.clone(), FulltextRequest::default())]),
928 factory.clone(),
929 &json_metadata(None),
930 );
931 assert!(applier.compatible_requests_for_sst(&metadata).is_none());
932 }
933 }
934
935 #[test]
936 fn test_ordinary_fulltext_sst_compatibility() {
937 let (_dir, factory) = PuffinManagerFactory::new_for_test_block("ordinary_fulltext_plan");
938 let metadata = mock_metadata_with_fulltext(FulltextBackend::Bloom);
939 let applier = FulltextIndexApplier::new(
940 "test".into(),
941 PathType::Bare,
942 object_store::ObjectStore::new(object_store::services::Memory::default()).unwrap(),
943 BTreeMap::from([(IndexTarget::ColumnId(1), FulltextRequest::default())]),
944 factory,
945 &metadata,
946 );
947 assert!(applier.compatible_requests_for_sst(&metadata).is_some());
948 assert!(
949 applier
950 .compatible_requests_for_sst(&mock_metadata_with_fulltext(FulltextBackend::Tantivy))
951 .is_some()
952 );
953 let mut non_string = metadata.clone();
954 non_string.column_metadatas[0].column_schema.data_type = ConcreteDataType::int64_datatype();
955 assert!(applier.compatible_requests_for_sst(&non_string).is_none());
956 assert!(
957 applier
958 .compatible_requests_for_sst(&mock_metadata_without_fulltext())
959 .is_some()
960 );
961 let mut disabled = metadata.clone();
962 disabled.column_metadatas[0]
963 .column_schema
964 .set_fulltext_options(&FulltextOptions {
965 enable: false,
966 ..Default::default()
967 })
968 .unwrap();
969 assert!(applier.compatible_requests_for_sst(&disabled).is_some());
970 }
971
972 enum Predicate<'a> {
974 Term(&'a str),
975 Like(&'a str),
976 LowerTerm(&'a str),
977 LowerLike(&'a str),
978 }
979
980 impl Predicate<'_> {
981 fn eval(&self, text: &str) -> bool {
982 let like_matches = |text: &str, pattern: &str| {
983 like(
984 &StringArray::from(vec![text]),
985 &Scalar::new(StringArray::from(vec![pattern])),
986 )
987 .unwrap()
988 .value(0)
989 };
990 match self {
991 Predicate::Term(term) => MatchesTermFinder::new(term).find(text),
992 Predicate::Like(pattern) => like_matches(text, pattern),
993 Predicate::LowerTerm(term) => {
994 MatchesTermFinder::new(term).find(&text.to_lowercase())
995 }
996 Predicate::LowerLike(pattern) => like_matches(&text.to_lowercase(), pattern),
997 }
998 }
999
1000 fn request(&self) -> FulltextRequest {
1001 let mut request = FulltextRequest::default();
1002 let (term, like, col_lowered) = match *self {
1003 Predicate::Term(term) => (Some(term), None, false),
1004 Predicate::Like(pattern) => (None, Some(pattern), false),
1005 Predicate::LowerTerm(term) => (Some(term), None, true),
1006 Predicate::LowerLike(pattern) => (None, Some(pattern), true),
1007 };
1008 request.terms.extend(term.map(|term| FulltextTerm {
1009 col_lowered,
1010 term: term.to_string(),
1011 }));
1012 request.likes.extend(like.map(|pattern| FulltextLike {
1013 col_lowered,
1014 pattern: pattern.to_string(),
1015 }));
1016 request
1017 }
1018
1019 fn assert_probes_indexed(&self, text: &str) -> usize {
1022 let mut num_probes = 0;
1023 for case_sensitive in [true, false] {
1024 let config = Config {
1025 analyzer: Analyzer::English,
1026 case_sensitive,
1027 };
1028 let tokens = tokenizer::Analyzer::new(Box::new(ScriptTokenizer), case_sensitive)
1029 .analyze_text(text)
1030 .unwrap()
1031 .into_iter()
1032 .collect::<HashSet<_>>();
1033 let predicates =
1034 FulltextIndexApplier::request_to_predicates(&self.request(), &config);
1035 for predicate in &predicates {
1036 assert!(
1037 predicate.list.iter().any(|probe| tokens.contains(probe)),
1038 "probe {:?} not indexed, text: {text:?}, case_sensitive: {case_sensitive}",
1039 predicate
1040 .list
1041 .iter()
1042 .map(|p| String::from_utf8_lossy(p))
1043 .collect::<Vec<_>>(),
1044 );
1045 }
1046 num_probes = predicates.len();
1047 }
1048 num_probes
1049 }
1050 }
1051
1052 #[test]
1053 fn test_probes_keep_matching_rows() {
1054 let cases = [
1056 ("hello_world", Predicate::Term("world"), true),
1057 ("trace_id=abc", Predicate::Term("id"), true),
1058 ("错误error日志", Predicate::Term("error"), true),
1059 (
1060 "登录手机号18888888888的动态key",
1061 Predicate::Term("手机号"),
1062 true,
1063 ),
1064 (
1065 "登录手机号18888888888的动态key",
1066 Predicate::Term("机号"),
1067 true,
1068 ),
1069 (
1070 "登录手机号18888888888的动态key",
1071 Predicate::Term("18888888888"),
1072 true,
1073 ),
1074 (
1076 "登录手机号18888888888的动态key",
1077 Predicate::Term("机号1888"),
1078 true,
1079 ),
1080 ("中国农业银行", Predicate::Term("农业"), true),
1081 ("中国农业银行", Predicate::Term("农"), false),
1082 ("连接timeout.5次", Predicate::Term("timeout"), true),
1083 ("用户user-123登录", Predicate::Term("user"), true),
1084 (
1085 "CRITICAL error: disk",
1086 Predicate::Term("CRITICAL error"),
1087 true,
1088 ),
1089 ("café>", Predicate::Term("café"), true),
1090 (
1091 "connection timeout after 5s",
1092 Predicate::Like("%timeout%"),
1093 false,
1094 ),
1095 (
1096 "connection timeout after 5s",
1097 Predicate::Like("% timeout %"),
1098 true,
1099 ),
1100 (
1101 "connection timeout after 5s",
1102 Predicate::Like("connection%"),
1103 false,
1104 ),
1105 (
1106 "connection timeout after 5s",
1107 Predicate::Like("connection %"),
1108 true,
1109 ),
1110 (
1111 "connection timeout after 5s",
1112 Predicate::Like("%after 5s"),
1113 true,
1114 ),
1115 ("trace_id=abc", Predicate::Like(r"trace\_id=%"), true),
1116 ("trace_id=abc", Predicate::Like("trace_id=%"), false),
1117 ("100% done", Predicate::Like(r"100\% done"), true),
1118 ("错误error日志", Predicate::Like("%错误error日%"), true),
1119 ("错误error日志", Predicate::Like("%r日志"), true),
1120 ];
1121 let lowered_cases = [
1122 (
1123 "Connection TIMEOUT after 5s",
1124 Predicate::LowerTerm("timeout"),
1125 true,
1126 ),
1127 (
1128 "Connection TIMEOUT after 5s",
1129 Predicate::LowerLike("% timeout %"),
1130 true,
1131 ),
1132 ("a\u{212A}b", Predicate::LowerTerm("akb"), true),
1135 ("\u{130}stanbul", Predicate::LowerTerm("stanbul"), true),
1136 ("ΟΔΟΣ.x", Predicate::LowerTerm("οδοσ"), true),
1137 ];
1138 for (text, predicate, has_probes) in cases.into_iter().chain(lowered_cases) {
1139 assert!(predicate.eval(text), "case doesn't match: {text:?}");
1140 assert_eq!(
1141 predicate.assert_probes_indexed(text) > 0,
1142 has_probes,
1143 "text: {text:?}"
1144 );
1145 }
1146 }
1147
1148 #[test]
1149 fn test_probes_keep_matching_rows_random() {
1150 const ALPHABET: &[char] = &[
1153 'a', 'b', 'A', '1', '_', '-', ' ', '.', '%', '\\', '错', '误', 'é', 'Д', '\u{212A}',
1154 '\u{130}', 'Σ',
1155 ];
1156 let mut rng = StdRng::seed_from_u64(42);
1157 let random_char = |rng: &mut StdRng| ALPHABET[rng.random_range(0..ALPHABET.len())];
1158
1159 for _ in 0..20000 {
1160 let text = (0..rng.random_range(0..12))
1161 .map(|_| random_char(&mut rng))
1162 .collect::<String>();
1163 let chars = text.chars().collect::<Vec<_>>();
1164 let start = rng.random_range(0..=chars.len());
1165 let end = rng.random_range(start..=chars.len());
1166
1167 let term = chars[start..end].iter().collect::<String>();
1168 let lowered = term.to_lowercase();
1169 for predicate in [Predicate::Term(&term), Predicate::LowerTerm(&lowered)] {
1170 if predicate.eval(&text) {
1171 predicate.assert_probes_indexed(&text);
1172 }
1173 }
1174
1175 let mut pattern = String::new();
1176 if start > 0 && rng.random_bool(0.5) {
1177 pattern.push('%');
1178 }
1179 for &c in &chars[start..end] {
1180 match rng.random_range(0..10) {
1181 0 => pattern.push('%'),
1182 1 => pattern.push('_'),
1183 _ if matches!(c, '%' | '_' | '\\') => {
1184 pattern.push('\\');
1185 pattern.push(c);
1186 }
1187 _ => pattern.push(c),
1188 }
1189 }
1190 if end < chars.len() && rng.random_bool(0.5) {
1191 pattern.push('%');
1192 }
1193 let lowered = pattern.to_lowercase();
1194 for predicate in [Predicate::Like(&pattern), Predicate::LowerLike(&lowered)] {
1195 if predicate.eval(&text) {
1196 predicate.assert_probes_indexed(&text);
1197 }
1198 }
1199 }
1200 }
1201 pub(crate) fn json_metadata(options: Option<FulltextOptions>) -> RegionMetadata {
1202 let mut builder = RegionMetadataBuilder::from_existing(mock_metadata_with_fulltext(
1203 FulltextBackend::Bloom,
1204 ));
1205 let mut column =
1206 ColumnSchema::new("attrs", ConcreteDataType::json2(Default::default()), true);
1207 column.with_extension_type(&Json2ExtensionType::new(Arc::new(JsonMetadata::new(
1208 JsonSettings::try_new(
1209 vec![
1210 JsonTypeHint {
1211 path: vec!["message".into()],
1212 data_type: ConcreteDataType::string_datatype(),
1213 inverted_index: false,
1214 skipping_index: None,
1215 fulltext_index: options,
1216 },
1217 JsonTypeHint {
1218 path: vec!["other".into()],
1219 data_type: ConcreteDataType::string_datatype(),
1220 inverted_index: false,
1221 skipping_index: None,
1222 fulltext_index: Some(FulltextOptions {
1223 enable: true,
1224 ..Default::default()
1225 }),
1226 },
1227 ],
1228 Some(0),
1229 )
1230 .unwrap(),
1231 ))));
1232 builder.push_column_metadata(ColumnMetadata {
1233 column_schema: column,
1234 semantic_type: SemanticType::Field,
1235 column_id: 3,
1236 });
1237 builder.build().unwrap()
1238 }
1239
1240 pub(crate) fn mock_metadata_without_fulltext() -> RegionMetadata {
1241 let mut builder = RegionMetadataBuilder::new(RegionId::new(1, 2));
1242 builder
1243 .push_column_metadata(ColumnMetadata {
1244 column_schema: ColumnSchema::new("text", ConcreteDataType::string_datatype(), true),
1245 semantic_type: SemanticType::Field,
1246 column_id: 1,
1247 })
1248 .push_column_metadata(ColumnMetadata {
1249 column_schema: ColumnSchema::new(
1250 "ts",
1251 ConcreteDataType::timestamp_millisecond_datatype(),
1252 false,
1253 ),
1254 semantic_type: SemanticType::Timestamp,
1255 column_id: 2,
1256 });
1257
1258 builder.build().unwrap()
1259 }
1260
1261 pub(crate) fn mock_metadata_with_fulltext(backend: FulltextBackend) -> RegionMetadata {
1262 let mut metadata = mock_metadata_without_fulltext();
1263 metadata.column_metadatas[0]
1264 .column_schema
1265 .set_fulltext_options(&FulltextOptions {
1266 enable: true,
1267 backend,
1268 ..Default::default()
1269 })
1270 .unwrap();
1271 RegionMetadataBuilder::from_existing(metadata)
1272 .build()
1273 .unwrap()
1274 }
1275}