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 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/// Metrics for tracking fulltext index apply operations.
59#[derive(Default, Clone)]
60pub struct FulltextIndexApplyMetrics {
61    /// Total time spent applying the index.
62    pub apply_elapsed: std::time::Duration,
63    /// Number of blob cache misses.
64    pub blob_cache_miss: usize,
65    /// Number of directory cache hits.
66    pub dir_cache_hit: usize,
67    /// Number of directory cache misses.
68    pub dir_cache_miss: usize,
69    /// Elapsed time to initialize directory data.
70    pub dir_init_elapsed: std::time::Duration,
71    /// Metrics for bloom filter reads.
72    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    /// Returns true if the metrics are empty (contain no meaningful data).
117    pub fn is_empty(&self) -> bool {
118        self.apply_elapsed.is_zero()
119    }
120
121    /// Collects metrics from a directory read operation.
122    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    /// Merges another metrics into this one.
136    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
147/// `FulltextIndexApplier` is responsible for applying fulltext index to the provided SST files
148pub struct FulltextIndexApplier {
149    /// Requests to be applied.
150    requests: Arc<BTreeMap<IndexTarget, FulltextRequest>>,
151
152    /// Expected fulltext settings from the current schema.
153    expected_options: BTreeMap<IndexTarget, FulltextOptions>,
154
155    /// The source of the index.
156    index_source: IndexSource,
157
158    /// Cache for bloom filter index.
159    bloom_filter_index_cache: Option<BloomFilterIndexCacheRef>,
160}
161
162pub type FulltextIndexApplierRef = Arc<FulltextIndexApplier>;
163
164impl FulltextIndexApplier {
165    /// Creates a new `FulltextIndexApplier`.
166    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    /// Keeps ordinary string columns and JSON paths with compatible saved fulltext settings.
196    /// Returns `None` when no compatible request remains for this SST.
197    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    /// Sets the file cache.
221    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    /// Sets the puffin metadata cache.
227    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    /// Sets the bloom filter cache.
237    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            // Rebuilding an ordinary column's index does not update Parquet metadata.
251            // Check only its type here; index readers use the actual blob's config.
252            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        // JSON path hints can change backend, case sensitivity, and analyzer.
264        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    /// Applies fine-grained fulltext index to the specified SST file.
272    /// Returns the row ids that match the queries.
273    ///
274    /// # Arguments
275    /// * `file_id` - The region file ID to apply predicates to
276    /// * `file_size_hint` - Optional hint for file size to avoid extra metadata reads
277    /// * `requests` - Compatible requests for this SST
278    /// * `metrics` - Optional mutable reference to collect metrics on demand
279    #[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            // Tantivy tokens don't follow `matches_term` boundaries, e.g. `SimpleTokenizer`
295            // keeps "错误error日志" as one token, so terms and patterns are left to the
296            // bloom backend.
297            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        // Record elapsed time to histogram and collect metrics if requested
328        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    /// Applies coarse-grained fulltext index to the specified SST file.
394    /// Returns (row group id -> ranges) that match the queries.
395    ///
396    /// Row group id existing in the returned result means that the row group is searched.
397    /// Empty ranges means that the row group is searched but no rows are found.
398    ///
399    /// # Arguments
400    /// * `file_id` - The region file ID to apply predicates to
401    /// * `file_size_hint` - Optional hint for file size to avoid extra metadata reads
402    /// * `row_groups` - Iterator of row group lengths and whether to search in the row group
403    /// * `requests` - Compatible requests for this SST
404    /// * `metrics` - Optional mutable reference to collect metrics on demand
405    #[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        // Record elapsed time to histogram and collect metrics if requested
447        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    /// Initializes the coarse output. Must call `adjust_coarse_output` after applying bloom filters.
523    ///
524    /// `row_groups` is a list of (row group length, whether to search).
525    ///
526    /// Returns (`input`, `output`):
527    /// * `input` is a list of (row group index to search, row group range based on start of the file).
528    /// * `output` is a list of (row group index to search, row group ranges based on start of the file).
529    #[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        // Calculates row groups' ranges based on start of the file.
534        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        // Initializes output with input ranges, but ranges are based on start of the file not the row group,
545        // so we need to adjust them later.
546        let output = input
547            .iter()
548            .map(|(i, range)| (*i, vec![range.clone()]))
549            .collect::<Vec<_>>();
550
551        (input, output)
552    }
553
554    /// Adjusts the coarse output. Makes the output ranges based on row group start.
555    fn adjust_coarse_output(
556        input: Vec<(usize, Range<usize>)>,
557        output: &mut [(usize, Vec<Range<usize>>)],
558    ) {
559        // adjust ranges to be based on row group
560        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    /// Converts terms and `LIKE` patterns to predicates, one per token that a matching
570    /// row must contain. Multiple predicates are combined with AND semantics.
571    fn request_to_predicates(request: &FulltextRequest, config: &Config) -> Vec<InListPredicate> {
572        // Probes of a lowercased column come from lowercased text and are indexed as is;
573        // a case-sensitive index has no tokens of the lowercased text.
574        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/// The source of the index.
605#[derive(Clone)]
606struct IndexSource {
607    table_dir: String,
608
609    /// Path type for generating file paths.
610    path_type: PathType,
611
612    /// The puffin manager factory.
613    puffin_manager_factory: PuffinManagerFactory,
614
615    /// Store responsible for accessing remote index files.
616    remote_store: ObjectStore,
617
618    /// Local file cache.
619    file_cache: Option<FileCacheRef>,
620
621    /// The puffin metadata cache.
622    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    /// Returns the blob with the specified key from local cache or remote store.
651    ///
652    /// Returns `None` if the blob is not found.
653    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        // Track cache miss if fallbacked to remote
663        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    /// Returns the directory with the specified key from local cache or remote store.
689    ///
690    /// Returns `None` if the directory is not found.
691    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        // Track cache miss if fallbacked to remote
701        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                    // Safety: start is Some when metrics is Some
711                    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                                // Safety: start is Some when metrics is Some
728                                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    /// Return reader and whether it is fallbacked to remote store.
741    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 background download if file cache and file size are available
794        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            // An unresolved query-side target must not become compatible merely
912            // because the expected options map has no entry for it.
913            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            // Missing current options are also fail-closed even if the SST has them.
923            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    /// A `matches_term` or `LIKE` predicate on `col`, or on `lower(col)` when `lowered`.
973    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        /// Asserts that every probe is among `text`'s indexed tokens, i.e. the bloom
1020        /// filter keeps the row, and returns the number of probes.
1021        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        // (text, predicate, whether the predicate yields probes)
1055        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            // The ASCII edge of a Han term may continue in the text.
1075            (
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            // Lowercasing changes the class of U+212A KELVIN SIGN, splits U+0130 into
1133            // two characters, and lowercases `Σ` depending on what follows it.
1134            ("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        // Word characters of every class, separators, LIKE metacharacters, and
1151        // characters whose lowercase changes length or class.
1152        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}