Skip to main content

mito2/sst/index/fulltext_index/
applier.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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/// Metrics for tracking fulltext index apply operations.
58#[derive(Default, Clone)]
59pub struct FulltextIndexApplyMetrics {
60    /// Total time spent applying the index.
61    pub apply_elapsed: std::time::Duration,
62    /// Number of blob cache misses.
63    pub blob_cache_miss: usize,
64    /// Number of directory cache hits.
65    pub dir_cache_hit: usize,
66    /// Number of directory cache misses.
67    pub dir_cache_miss: usize,
68    /// Elapsed time to initialize directory data.
69    pub dir_init_elapsed: std::time::Duration,
70    /// Metrics for bloom filter reads.
71    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    /// Returns true if the metrics are empty (contain no meaningful data).
116    pub fn is_empty(&self) -> bool {
117        self.apply_elapsed.is_zero()
118    }
119
120    /// Collects metrics from a directory read operation.
121    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    /// Merges another metrics into this one.
135    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
146/// `FulltextIndexApplier` is responsible for applying fulltext index to the provided SST files
147pub struct FulltextIndexApplier {
148    /// Requests to be applied.
149    requests: Arc<BTreeMap<ColumnId, FulltextRequest>>,
150
151    /// The source of the index.
152    index_source: IndexSource,
153
154    /// Cache for bloom filter index.
155    bloom_filter_index_cache: Option<BloomFilterIndexCacheRef>,
156
157    /// Predicate key. Used to identify the predicate and fetch result from cache.
158    predicate_key: PredicateKey,
159}
160
161pub type FulltextIndexApplierRef = Arc<FulltextIndexApplier>;
162
163impl FulltextIndexApplier {
164    /// Creates a new `FulltextIndexApplier`.
165    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    /// Sets the file cache.
184    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    /// Sets the puffin metadata cache.
190    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    /// Sets the bloom filter cache.
200    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    /// Returns the predicate key.
209    pub fn predicate_key(&self) -> &PredicateKey {
210        &self.predicate_key
211    }
212}
213
214impl FulltextIndexApplier {
215    /// Applies fine-grained fulltext index to the specified SST file.
216    /// Returns the row ids that match the queries.
217    ///
218    /// # Arguments
219    /// * `file_id` - The region file ID to apply predicates to
220    /// * `file_size_hint` - Optional hint for file size to avoid extra metadata reads
221    /// * `metrics` - Optional mutable reference to collect metrics on demand
222    #[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            // Tantivy tokens don't follow `matches_term` boundaries, e.g. `SimpleTokenizer`
237            // keeps "错误error日志" as one token, so terms and patterns are left to the
238            // bloom backend.
239            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        // Record elapsed time to histogram and collect metrics if requested
270        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    /// Applies coarse-grained fulltext index to the specified SST file.
338    /// Returns (row group id -> ranges) that match the queries.
339    ///
340    /// Row group id existing in the returned result means that the row group is searched.
341    /// Empty ranges means that the row group is searched but no rows are found.
342    ///
343    /// # Arguments
344    /// * `file_id` - The region file ID to apply predicates to
345    /// * `file_size_hint` - Optional hint for file size to avoid extra metadata reads
346    /// * `row_groups` - Iterator of row group lengths and whether to search in the row group
347    /// * `metrics` - Optional mutable reference to collect metrics on demand
348    #[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        // Record elapsed time to histogram and collect metrics if requested
389        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    /// Initializes the coarse output. Must call `adjust_coarse_output` after applying bloom filters.
467    ///
468    /// `row_groups` is a list of (row group length, whether to search).
469    ///
470    /// Returns (`input`, `output`):
471    /// * `input` is a list of (row group index to search, row group range based on start of the file).
472    /// * `output` is a list of (row group index to search, row group ranges based on start of the file).
473    #[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        // Calculates row groups' ranges based on start of the file.
478        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        // Initializes output with input ranges, but ranges are based on start of the file not the row group,
489        // so we need to adjust them later.
490        let output = input
491            .iter()
492            .map(|(i, range)| (*i, vec![range.clone()]))
493            .collect::<Vec<_>>();
494
495        (input, output)
496    }
497
498    /// Adjusts the coarse output. Makes the output ranges based on row group start.
499    fn adjust_coarse_output(
500        input: Vec<(usize, Range<usize>)>,
501        output: &mut [(usize, Vec<Range<usize>>)],
502    ) {
503        // adjust ranges to be based on row group
504        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    /// Converts terms and `LIKE` patterns to predicates, one per token that a matching
514    /// row must contain. Multiple predicates are combined with AND semantics.
515    fn request_to_predicates(request: &FulltextRequest, config: &Config) -> Vec<InListPredicate> {
516        // Probes of a lowercased column come from lowercased text and are indexed as is;
517        // a case-sensitive index has no tokens of the lowercased text.
518        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
548/// The source of the index.
549struct IndexSource {
550    table_dir: String,
551
552    /// Path type for generating file paths.
553    path_type: PathType,
554
555    /// The puffin manager factory.
556    puffin_manager_factory: PuffinManagerFactory,
557
558    /// Store responsible for accessing remote index files.
559    remote_store: ObjectStore,
560
561    /// Local file cache.
562    file_cache: Option<FileCacheRef>,
563
564    /// The puffin metadata cache.
565    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    /// Returns the blob with the specified key from local cache or remote store.
594    ///
595    /// Returns `None` if the blob is not found.
596    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        // Track cache miss if fallbacked to remote
606        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    /// Returns the directory with the specified key from local cache or remote store.
632    ///
633    /// Returns `None` if the directory is not found.
634    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        // Track cache miss if fallbacked to remote
644        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                    // Safety: start is Some when metrics is Some
654                    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                                // Safety: start is Some when metrics is Some
671                                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    /// Return reader and whether it is fallbacked to remote store.
684    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 background download if file cache and file size are available
737        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    /// A `matches_term` or `LIKE` predicate on `col`, or on `lower(col)` when `lowered`.
774    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        /// Asserts that every probe is among `text`'s indexed tokens, i.e. the bloom
821        /// filter keeps the row, and returns the number of probes.
822        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        // (text, predicate, whether the predicate yields probes)
856        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            // The ASCII edge of a Han term may continue in the text.
876            (
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            // Lowercasing changes the class of U+212A KELVIN SIGN, splits U+0130 into
934            // two characters, and lowercases `Σ` depending on what follows it.
935            ("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        // Word characters of every class, separators, LIKE metacharacters, and
952        // characters whose lowercase changes length or class.
953        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}