Skip to main content

mito2/sst/
file.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
15//! Structures to describe metadata of files.
16
17use std::collections::HashMap;
18use std::fmt;
19use std::fmt::{Debug, Formatter};
20use std::num::NonZeroU64;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex, RwLock};
23
24use base64::prelude::{BASE64_STANDARD, Engine};
25use bytes::Bytes;
26use common_base::readable_size::ReadableSize;
27use common_telemetry::{debug, error, warn};
28use common_time::Timestamp;
29use datatypes::schema::FulltextBackend;
30use partition::expr::PartitionExpr;
31use serde::{Deserialize, Serialize};
32use smallvec::SmallVec;
33use store_api::metadata::ColumnMetadata;
34use store_api::region_request::PathType;
35use store_api::storage::{ColumnId, FileId, IndexVersion, RegionId};
36
37use crate::access_layer::AccessLayerRef;
38use crate::cache::CacheManagerRef;
39use crate::cache::file_cache::{FileType, IndexKey};
40use crate::sst::file_purger::FilePurgerRef;
41use crate::sst::index::fulltext_index::FULLTEXT_BLOOM_VERSION;
42use crate::sst::location;
43use crate::sst::parquet::SstInfo;
44use crate::sst::primary_key::PrimaryKeyRangeMapper;
45
46/// Custom serde functions for Bytes fields serialized as base64 strings.
47fn serialize_bytes_option<S>(bytes: &Option<Bytes>, serializer: S) -> Result<S::Ok, S::Error>
48where
49    S: serde::Serializer,
50{
51    match bytes {
52        None => serializer.serialize_none(),
53        Some(b) => serializer.serialize_some(&BASE64_STANDARD.encode(b)),
54    }
55}
56
57fn deserialize_bytes_option<'de, D>(deserializer: D) -> Result<Option<Bytes>, D::Error>
58where
59    D: serde::Deserializer<'de>,
60{
61    let opt: Option<String> = Option::deserialize(deserializer)?;
62    match opt {
63        None => Ok(None),
64        Some(s) => {
65            let decoded = BASE64_STANDARD
66                .decode(&s)
67                .map_err(serde::de::Error::custom)?;
68            Ok(Some(Bytes::from(decoded)))
69        }
70    }
71}
72
73/// Custom serde functions for partition_expr field in FileMeta
74fn serialize_partition_expr<S>(
75    partition_expr: &Option<PartitionExpr>,
76    serializer: S,
77) -> Result<S::Ok, S::Error>
78where
79    S: serde::Serializer,
80{
81    use serde::ser::Error;
82
83    match partition_expr {
84        None => serializer.serialize_none(),
85        Some(expr) => {
86            let json_str = expr.as_json_str().map_err(S::Error::custom)?;
87            serializer.serialize_some(&json_str)
88        }
89    }
90}
91
92fn deserialize_partition_expr<'de, D>(deserializer: D) -> Result<Option<PartitionExpr>, D::Error>
93where
94    D: serde::Deserializer<'de>,
95{
96    use serde::de::Error;
97
98    let opt_json_str: Option<String> = Option::deserialize(deserializer)?;
99    match opt_json_str {
100        None => Ok(None),
101        Some(json_str) => {
102            if json_str.is_empty() {
103                // Empty string represents explicit "single-region/no-partition" designation
104                Ok(None)
105            } else {
106                // Parse the JSON string to PartitionExpr
107                PartitionExpr::from_json_str(&json_str).map_err(D::Error::custom)
108            }
109        }
110    }
111}
112
113/// Type to store SST level.
114pub type Level = u8;
115/// Maximum level of SSTs.
116pub const MAX_LEVEL: Level = 2;
117/// Type to store index types for a column.
118pub type IndexTypes = SmallVec<[IndexType; 4]>;
119
120/// Cross-region file id.
121///
122/// It contains a region id and a file id. The string representation is `{region_id}/{file_id}`.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124pub struct RegionFileId {
125    /// The region that creates the file.
126    region_id: RegionId,
127    /// The id of the file.
128    file_id: FileId,
129}
130
131impl RegionFileId {
132    /// Creates a new [RegionFileId] from `region_id` and `file_id`.
133    pub fn new(region_id: RegionId, file_id: FileId) -> Self {
134        Self { region_id, file_id }
135    }
136
137    /// Gets the region id.
138    pub fn region_id(&self) -> RegionId {
139        self.region_id
140    }
141
142    /// Gets the file id.
143    pub fn file_id(&self) -> FileId {
144        self.file_id
145    }
146}
147
148impl fmt::Display for RegionFileId {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        write!(f, "{}/{}", self.region_id, self.file_id)
151    }
152}
153
154/// Unique identifier for an index file, combining the SST file ID and the index version.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
156pub struct RegionIndexId {
157    pub file_id: RegionFileId,
158    pub version: IndexVersion,
159}
160
161impl RegionIndexId {
162    pub fn new(file_id: RegionFileId, version: IndexVersion) -> Self {
163        Self { file_id, version }
164    }
165
166    pub fn region_id(&self) -> RegionId {
167        self.file_id.region_id
168    }
169
170    pub fn file_id(&self) -> FileId {
171        self.file_id.file_id
172    }
173}
174
175impl fmt::Display for RegionIndexId {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        if self.version == 0 {
178            write!(f, "{}/{}", self.file_id.region_id, self.file_id.file_id)
179        } else {
180            write!(
181                f,
182                "{}/{}.{}",
183                self.file_id.region_id, self.file_id.file_id, self.version
184            )
185        }
186    }
187}
188
189/// Time range (min and max timestamps) of a SST file.
190/// Both min and max are inclusive.
191pub type FileTimeRange = (Timestamp, Timestamp);
192
193/// Checks if two inclusive timestamp ranges overlap with each other.
194pub(crate) fn overlaps(l: &FileTimeRange, r: &FileTimeRange) -> bool {
195    let (l, r) = if l.0 <= r.0 { (l, r) } else { (r, l) };
196    let (_, l_end) = l;
197    let (r_start, _) = r;
198
199    r_start <= l_end
200}
201
202/// Metadata of a SST file.
203#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
204#[serde(default)]
205pub struct FileMeta {
206    /// Region that created the file. The region id may not be the id of the current region.
207    pub region_id: RegionId,
208    /// Compared to normal file names, FileId ignore the extension
209    pub file_id: FileId,
210    /// Timestamp range of file. The timestamps have the same time unit as the
211    /// data in the SST.
212    pub time_range: FileTimeRange,
213    /// SST level of the file.
214    pub level: Level,
215    /// Size of the file.
216    pub file_size: u64,
217    /// Maximum uncompressed row group size of the file. 0 means unknown.
218    pub max_row_group_uncompressed_size: u64,
219    /// Available indexes of the file.
220    pub available_indexes: IndexTypes,
221    /// Created indexes of the file for each column.
222    ///
223    /// This is essentially a more granular, column-level version of `available_indexes`,
224    /// primarily used for manual index building in the asynchronous index construction mode.
225    ///
226    /// For backward compatibility, older `FileMeta` versions might only contain `available_indexes`.
227    /// In such cases, we cannot deduce specific column index information from `available_indexes` alone.
228    /// Therefore, defaulting this `indexes` field to an empty list during deserialization is a
229    /// reasonable and necessary step to ensure column information consistency.
230    pub indexes: Vec<ColumnIndexMetadata>,
231    /// Size of the index file.
232    pub index_file_size: u64,
233    /// Version of the index file.
234    /// Used to generate the index file name: "{file_id}.{index_version}.puffin".
235    /// Default is 0 (which maps to "{file_id}.puffin" for compatibility).
236    pub index_version: u64,
237    /// Number of rows in the file.
238    ///
239    /// For historical reasons, this field might be missing in old files. Thus
240    /// the default value `0` doesn't means the file doesn't contains any rows,
241    /// but instead means the number of rows is unknown.
242    pub num_rows: u64,
243    /// Number of row groups in the file.
244    ///
245    /// For historical reasons, this field might be missing in old files. Thus
246    /// the default value `0` doesn't means the file doesn't contains any rows,
247    /// but instead means the number of rows is unknown.
248    pub num_row_groups: u64,
249    /// File-level sequence bound or admission marker in the target region.
250    ///
251    /// Flush records the maximum input row sequence. Compaction outputs inherit
252    /// the maximum input bound, or remain unknown if any input bound is unknown,
253    /// independently of per-row sequence trust. This does not imply that every
254    /// physical row has this sequence.
255    /// Readers also use it to normalize foreign and legacy all-zero files.
256    pub sequence: Option<NonZeroU64>,
257    /// Partition expression from the region metadata when the file is created.
258    ///
259    /// This is stored as a PartitionExpr object in memory for convenience,
260    /// but serialized as JSON string for manifest compatibility.
261    /// Compatibility behavior:
262    /// - None: no partition expr was set when the file was created (legacy files).
263    /// - Some(expr): partition expression from region metadata.
264    #[serde(
265        serialize_with = "serialize_partition_expr",
266        deserialize_with = "deserialize_partition_expr"
267    )]
268    pub partition_expr: Option<PartitionExpr>,
269    /// Number of series in the file.
270    ///
271    /// The number is 0 if the series number is not available.
272    pub num_series: u64,
273    /// Minimum primary key value in the file, encoded as bytes.
274    /// `None` if the primary key range is not available (e.g., legacy files).
275    #[serde(
276        default,
277        skip_serializing_if = "Option::is_none",
278        serialize_with = "serialize_bytes_option",
279        deserialize_with = "deserialize_bytes_option"
280    )]
281    pub primary_key_min: Option<Bytes>,
282    /// Maximum primary key value in the file, encoded as bytes.
283    /// `None` if the primary key range is not available (e.g., legacy files).
284    #[serde(
285        default,
286        skip_serializing_if = "Option::is_none",
287        serialize_with = "serialize_bytes_option",
288        deserialize_with = "deserialize_bytes_option"
289    )]
290    pub primary_key_max: Option<Bytes>,
291    /// Whether the file preserves per-row sequence numbers usable for exact
292    /// row-level sequence filtering.
293    /// Merely retaining physical input sequences during compaction does not
294    /// restore this capability for untrusted inputs.
295    #[serde(default, skip_serializing_if = "is_false")]
296    pub preserve_row_sequence: bool,
297}
298
299fn is_false(value: &bool) -> bool {
300    !*value
301}
302
303/// Formats a debug field with a custom closure, as a stable replacement for
304/// the unstable `DebugStruct::field_with`.
305struct DebugFmt<F: Fn(&mut Formatter<'_>) -> fmt::Result>(F);
306
307impl<F: Fn(&mut Formatter<'_>) -> fmt::Result> std::fmt::Debug for DebugFmt<F> {
308    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
309        (self.0)(f)
310    }
311}
312
313impl Debug for FileMeta {
314    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
315        let mut debug_struct = f.debug_struct("FileMeta");
316        debug_struct
317            .field("region_id", &self.region_id)
318            .field("file_id", &DebugFmt(|f| write!(f, "{} ", self.file_id)))
319            .field(
320                "time_range",
321                &DebugFmt(|f| {
322                    write!(
323                        f,
324                        "({}, {}) ",
325                        self.time_range.0.to_iso8601_string(),
326                        self.time_range.1.to_iso8601_string()
327                    )
328                }),
329            )
330            .field("level", &self.level)
331            .field("file_size", &ReadableSize(self.file_size))
332            .field(
333                "max_row_group_uncompressed_size",
334                &ReadableSize(self.max_row_group_uncompressed_size),
335            );
336        if !self.available_indexes.is_empty() {
337            debug_struct
338                .field("available_indexes", &self.available_indexes)
339                .field("indexes", &self.indexes)
340                .field("index_file_size", &ReadableSize(self.index_file_size));
341        }
342        debug_struct
343            .field("num_rows", &self.num_rows)
344            .field("num_row_groups", &self.num_row_groups)
345            .field(
346                "sequence",
347                &DebugFmt(|f| match self.sequence {
348                    None => {
349                        write!(f, "None")
350                    }
351                    Some(seq) => {
352                        write!(f, "{}", seq)
353                    }
354                }),
355            )
356            .field("partition_expr", &self.partition_expr)
357            .field("num_series", &self.num_series);
358        if self.primary_key_min.is_some() || self.primary_key_max.is_some() {
359            debug_struct
360                .field(
361                    "primary_key_min",
362                    &self.primary_key_min.as_ref().map(|b| b.len()),
363                )
364                .field(
365                    "primary_key_max",
366                    &self.primary_key_max.as_ref().map(|b| b.len()),
367                );
368        }
369        debug_struct.finish()
370    }
371}
372
373/// Type of index.
374#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
375pub enum IndexType {
376    /// Inverted index.
377    InvertedIndex,
378    /// Full-text index.
379    FulltextIndex,
380    /// Bloom Filter index
381    BloomFilterIndex,
382    /// Retained for manifests written with the removed experimental vector index.
383    VectorIndex,
384}
385
386/// Metadata of indexes created for a specific column in an SST file.
387///
388/// This structure tracks which index types have been successfully created for a column.
389/// It provides more granular, column-level index information compared to the file-level
390/// `available_indexes` field in [`FileMeta`].
391///
392/// This is primarily used for:
393/// - Manual index building in asynchronous index construction mode
394/// - Verifying index consistency between files and region metadata
395#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
396#[serde(default)]
397pub struct ColumnIndexMetadata {
398    /// The column ID this index metadata applies to.
399    pub column_id: ColumnId,
400    /// List of index types that have been successfully created for this column.
401    pub created_indexes: IndexTypes,
402    /// Version of the column's fulltext bloom index. `0` means not recorded: the column
403    /// has no bloom index, or its index is the legacy format, which `ssts_index_meta`
404    /// reports as version 1.
405    #[serde(skip_serializing_if = "is_zero")]
406    pub fulltext_bloom_version: u8,
407}
408
409fn is_zero(version: &u8) -> bool {
410    *version == 0
411}
412
413impl FileMeta {
414    /// Returns the primary key range if both min and max are present.
415    pub fn primary_key_range(&self) -> Option<(Bytes, Bytes)> {
416        match (&self.primary_key_min, &self.primary_key_max) {
417            (Some(min), Some(max)) => Some((min.clone(), max.clone())),
418            _ => None,
419        }
420    }
421
422    pub fn exists_index(&self) -> bool {
423        !self.available_indexes.is_empty()
424    }
425
426    pub fn index_version(&self) -> Option<IndexVersion> {
427        if self.exists_index() {
428            Some(self.index_version)
429        } else {
430            None
431        }
432    }
433
434    /// Whether the index file is up-to-date comparing to another file meta.
435    pub fn is_index_up_to_date(&self, other: &FileMeta) -> bool {
436        self.exists_index() && other.exists_index() && self.index_version >= other.index_version
437    }
438
439    /// Returns true if the file has an inverted index
440    pub fn inverted_index_available(&self) -> bool {
441        self.available_indexes.contains(&IndexType::InvertedIndex)
442    }
443
444    /// Returns true if the file has a fulltext index
445    pub fn fulltext_index_available(&self) -> bool {
446        self.available_indexes.contains(&IndexType::FulltextIndex)
447    }
448
449    /// Returns true if the file has a bloom filter index.
450    pub fn bloom_filter_index_available(&self) -> bool {
451        self.available_indexes
452            .contains(&IndexType::BloomFilterIndex)
453    }
454
455    pub fn index_file_size(&self) -> u64 {
456        self.index_file_size
457    }
458
459    /// Check whether the file index is consistent with the given region metadata.
460    pub fn is_index_consistent_with_region(&self, metadata: &[ColumnMetadata]) -> bool {
461        let id_to_indexes = self
462            .indexes
463            .iter()
464            .map(|index| (index.column_id, index))
465            .collect::<std::collections::HashMap<_, _>>();
466        for column in metadata {
467            if !column.column_schema.is_indexed() {
468                continue;
469            }
470            if let Some(index) = id_to_indexes.get(&column.column_id) {
471                let indexes = &index.created_indexes;
472                if column.column_schema.is_inverted_indexed()
473                    && !indexes.contains(&IndexType::InvertedIndex)
474                {
475                    return false;
476                }
477                if column.column_schema.is_fulltext_indexed()
478                    && !indexes.contains(&IndexType::FulltextIndex)
479                {
480                    return false;
481                }
482                if column
483                    .column_schema
484                    .fulltext_options()
485                    .ok()
486                    .flatten()
487                    .is_some_and(|options| {
488                        options.enable && options.backend == FulltextBackend::Bloom
489                    })
490                    && index.fulltext_bloom_version < FULLTEXT_BLOOM_VERSION
491                {
492                    debug!(
493                        "Fulltext bloom index is outdated, region_id: {}, file_id: {}, column_id: {}, version: {}, current: {}",
494                        self.region_id,
495                        self.file_id,
496                        column.column_id,
497                        index.fulltext_bloom_version,
498                        FULLTEXT_BLOOM_VERSION
499                    );
500                    return false;
501                }
502                if column.column_schema.is_skipping_indexed()
503                    && !indexes.contains(&IndexType::BloomFilterIndex)
504                {
505                    return false;
506                }
507            } else {
508                return false;
509            }
510        }
511        true
512    }
513
514    /// Returns the cross-region file id.
515    pub fn file_id(&self) -> RegionFileId {
516        RegionFileId::new(self.region_id, self.file_id)
517    }
518
519    /// Returns the RegionIndexId for this file.
520    pub fn index_id(&self) -> RegionIndexId {
521        RegionIndexId::new(self.file_id(), self.index_version)
522    }
523}
524
525/// Handle to a SST file.
526#[derive(Clone)]
527pub struct FileHandle {
528    inner: Arc<FileHandleInner>,
529}
530
531impl fmt::Debug for FileHandle {
532    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
533        f.debug_struct("FileHandle")
534            .field("meta", self.meta_ref())
535            .field("compacting", &self.compacting())
536            .field("deleted", &self.inner.deleted.load(Ordering::Relaxed))
537            .finish()
538    }
539}
540
541impl FileHandle {
542    /// Creates a handle sharing the file's physical state and original statistics.
543    pub fn new(meta: FileMeta, file_purger: FilePurgerRef) -> FileHandle {
544        let pk_range = meta.primary_key_range();
545        FileHandle {
546            inner: Arc::new(FileHandleInner::new(meta, file_purger, pk_range)),
547        }
548    }
549
550    #[cfg(test)]
551    pub fn new_with_primary_key_range(
552        meta: FileMeta,
553        file_purger: FilePurgerRef,
554        primary_key_range: Option<(Bytes, Bytes)>,
555    ) -> FileHandle {
556        FileHandle {
557            inner: Arc::new(FileHandleInner::new(meta, file_purger, primary_key_range)),
558        }
559    }
560
561    /// Returns the region id of the file.
562    pub fn region_id(&self) -> RegionId {
563        self.inner.meta.region_id
564    }
565
566    /// Returns whether this file's row sequences are trusted in the target region.
567    ///
568    /// Foreign files use their target-local sequence barrier; local files require
569    /// the preserve marker because their physical sequences belong to this region.
570    pub(crate) fn is_effective_target_sequence_trusted(&self, target_region_id: RegionId) -> bool {
571        if self.region_id() != target_region_id {
572            self.meta_ref().sequence.is_some()
573        } else {
574            self.meta_ref().preserve_row_sequence
575        }
576    }
577
578    /// Returns the cross-region file id.
579    pub fn file_id(&self) -> RegionFileId {
580        RegionFileId::new(self.inner.meta.region_id, self.inner.meta.file_id)
581    }
582
583    /// Returns the RegionIndexId for this file.
584    pub fn index_id(&self) -> RegionIndexId {
585        RegionIndexId::new(self.file_id(), self.inner.meta.index_version)
586    }
587
588    /// Returns the complete file path of the file.
589    pub fn file_path(&self, table_dir: &str, path_type: PathType) -> String {
590        location::sst_file_path(table_dir, self.file_id(), path_type)
591    }
592
593    /// Returns the time range of the file.
594    pub fn time_range(&self) -> FileTimeRange {
595        self.inner.meta.time_range
596    }
597
598    /// Mark the file as deleted and will delete it on drop asynchronously
599    pub fn mark_deleted(&self) {
600        self.inner.deleted.store(true, Ordering::Relaxed);
601    }
602
603    pub fn compacting(&self) -> bool {
604        self.inner.compacting.load(Ordering::Relaxed)
605    }
606
607    pub fn set_compacting(&self, compacting: bool) {
608        self.inner.compacting.store(compacting, Ordering::Relaxed);
609    }
610
611    /// Atomically marks this file as compacting if it is currently available.
612    pub fn try_set_compacting(&self) -> bool {
613        self.inner
614            .compacting
615            .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
616            .is_ok()
617    }
618
619    pub fn index_outdated(&self) -> bool {
620        self.inner.index_outdated.load(Ordering::Relaxed)
621    }
622
623    pub fn set_index_outdated(&self, index_outdated: bool) {
624        self.inner
625            .index_outdated
626            .store(index_outdated, Ordering::Relaxed);
627    }
628
629    /// Returns a reference to the [FileMeta].
630    pub fn meta_ref(&self) -> &FileMeta {
631        &self.inner.meta
632    }
633
634    pub fn file_purger(&self) -> FilePurgerRef {
635        self.inner.file_purger.clone()
636    }
637
638    pub fn size(&self) -> u64 {
639        self.inner.meta.file_size
640    }
641
642    pub fn index_size(&self) -> u64 {
643        self.inner.meta.index_file_size
644    }
645
646    pub fn num_rows(&self) -> usize {
647        self.inner.meta.num_rows as usize
648    }
649
650    pub fn level(&self) -> Level {
651        self.inner.meta.level
652    }
653
654    pub fn is_deleted(&self) -> bool {
655        self.inner.deleted.load(Ordering::Relaxed)
656    }
657
658    /// Returns bounds aligned to the caller's pinned schema, before any comparison
659    /// or aggregation. Unknown and invalid statistics cannot exclude possible data.
660    pub(crate) fn primary_key_range(
661        &self,
662        mapper: &PrimaryKeyRangeMapper,
663    ) -> Option<(Bytes, Bytes)> {
664        debug_assert_eq!(self.region_id().table_id(), mapper.region_id().table_id());
665        if let Some(range) = self
666            .inner
667            .primary_key_range
668            .read()
669            .unwrap()
670            .aligned(mapper.schema_version())
671        {
672            return range.clone();
673        }
674        // Recheck under the write lock: another snapshot may have replaced the cached schema.
675        let aligned = self.inner.primary_key_range.write().unwrap().align(mapper);
676        match aligned {
677            Ok(range) => range,
678            Err(err) => {
679                warn!(err; "Invalid SST primary key range; using unknown bounds, region: {}, file: {}, schema version: {}",
680                    self.region_id(), self.file_id(), mapper.schema_version());
681                None
682            }
683        }
684    }
685
686    /// Returns original statistics for metadata hydration, never schema-aligned bounds.
687    pub fn raw_primary_key_range(&self) -> Option<(Bytes, Bytes)> {
688        self.inner.primary_key_range.read().unwrap().raw().cloned()
689    }
690
691    pub(crate) fn set_primary_key_range(&self, primary_key_range: (Bytes, Bytes)) {
692        // SST contents are immutable. Hydrate missing raw statistics without
693        // replacing the source of already cached schema views.
694        let mut range = self.inner.primary_key_range.write().unwrap();
695        if matches!(*range, PrimaryKeyRange::Missing) {
696            *range = PrimaryKeyRange::Raw(primary_key_range);
697        }
698    }
699}
700
701type PrimaryKeyBounds = (Bytes, Bytes);
702
703/// A single-slot cache shared by file handles. Always retain the source bounds so
704/// older snapshots and changed defaults can realign without interpreting padded values as stored.
705enum PrimaryKeyRange {
706    Missing,
707    Raw(PrimaryKeyBounds),
708    Aligned {
709        raw: PrimaryKeyBounds,
710        schema_version: u64,
711        bounds: Option<PrimaryKeyBounds>,
712    },
713}
714
715impl PrimaryKeyRange {
716    fn raw(&self) -> Option<&PrimaryKeyBounds> {
717        match self {
718            Self::Missing => None,
719            Self::Raw(raw) | Self::Aligned { raw, .. } => Some(raw),
720        }
721    }
722
723    fn aligned(&self, target_version: u64) -> Option<&Option<PrimaryKeyBounds>> {
724        match self {
725            Self::Aligned {
726                schema_version,
727                bounds,
728                ..
729            } if *schema_version == target_version => Some(bounds),
730            _ => None,
731        }
732    }
733
734    fn align(
735        &mut self,
736        mapper: &PrimaryKeyRangeMapper,
737    ) -> crate::error::Result<Option<PrimaryKeyBounds>> {
738        if let Some(bounds) = self.aligned(mapper.schema_version()) {
739            return Ok(bounds.clone());
740        }
741        let Some(raw) = self.raw().cloned() else {
742            return Ok(None);
743        };
744        let aligned = mapper.map(raw.clone());
745        // Failed mappings also occupy the cache, so repeated hits don't repeat the warning.
746        *self = Self::Aligned {
747            raw,
748            schema_version: mapper.schema_version(),
749            bounds: aligned.as_ref().ok().cloned().flatten(),
750        };
751        aligned
752    }
753}
754
755/// Inner data of [FileHandle].
756///
757/// Contains meta of the file, and other mutable info like whether the file is compacting.
758struct FileHandleInner {
759    meta: FileMeta,
760    compacting: AtomicBool,
761    deleted: AtomicBool,
762    index_outdated: AtomicBool,
763    primary_key_range: RwLock<PrimaryKeyRange>,
764    file_purger: FilePurgerRef,
765}
766
767impl Drop for FileHandleInner {
768    fn drop(&mut self) {
769        self.file_purger.remove_file(
770            self.meta.clone(),
771            self.deleted.load(Ordering::Acquire),
772            self.index_outdated.load(Ordering::Acquire),
773        );
774    }
775}
776
777impl FileHandleInner {
778    /// There should only be one `FileHandleInner` for each file on a datanode
779    fn new(
780        meta: FileMeta,
781        file_purger: FilePurgerRef,
782        primary_key_range: Option<(Bytes, Bytes)>,
783    ) -> FileHandleInner {
784        file_purger.new_file(&meta);
785        FileHandleInner {
786            meta,
787            compacting: AtomicBool::new(false),
788            deleted: AtomicBool::new(false),
789            index_outdated: AtomicBool::new(false),
790            primary_key_range: RwLock::new(
791                primary_key_range.map_or(PrimaryKeyRange::Missing, PrimaryKeyRange::Raw),
792            ),
793            file_purger,
794        }
795    }
796}
797
798/// Delete files for a region.
799/// - `region_id`: Region id.
800/// - `file_ids`: List of (file id, index version) tuples to delete.
801/// - `delete_index`: Whether to delete the index file from the cache.
802/// - `access_layer`: Access layer to delete files.
803/// - `cache_manager`: Cache manager to remove files from cache.
804pub async fn delete_files(
805    region_id: RegionId,
806    file_ids: &[(FileId, u64)],
807    delete_index: bool,
808    access_layer: &AccessLayerRef,
809    cache_manager: &Option<CacheManagerRef>,
810) -> crate::error::Result<()> {
811    // Remove meta of the file from cache.
812    if let Some(cache) = &cache_manager {
813        for (file_id, _) in file_ids {
814            cache.remove_parquet_meta_data(RegionFileId::new(region_id, *file_id));
815        }
816    }
817    let mut attempted_files = Vec::with_capacity(file_ids.len());
818    let mut index_ids = Vec::new();
819
820    for (file_id, index_version) in file_ids {
821        let region_file_id = RegionFileId::new(region_id, *file_id);
822        attempted_files.push(*file_id);
823        index_ids.extend(
824            (0..=*index_version).map(|version| RegionIndexId::new(region_file_id, version)),
825        );
826    }
827
828    access_layer
829        .delete_ssts(region_id, &attempted_files)
830        .await?;
831    access_layer.delete_indexes(&index_ids).await?;
832
833    debug!(
834        "Attempted to delete {} files for region {}: {:?}",
835        attempted_files.len(),
836        region_id,
837        attempted_files
838    );
839
840    for (file_id, index_version) in file_ids {
841        purge_index_cache_stager(
842            region_id,
843            delete_index,
844            access_layer,
845            cache_manager,
846            *file_id,
847            *index_version,
848        )
849        .await;
850    }
851    Ok(())
852}
853
854/// Tracks finalized SSTs until their manifest edit is committed.
855///
856/// Flush and local compaction jobs use this to remove files that were written successfully but
857/// abandoned by cancellation or a later failure.
858#[derive(Clone)]
859pub(crate) struct UncommittedSsts {
860    region_id: RegionId,
861    files: Arc<Mutex<HashMap<FileId, (u64, bool)>>>,
862    access_layer: AccessLayerRef,
863    cache_manager: Option<CacheManagerRef>,
864}
865
866impl UncommittedSsts {
867    pub(crate) fn new(
868        region_id: RegionId,
869        access_layer: AccessLayerRef,
870        cache_manager: Option<CacheManagerRef>,
871    ) -> Self {
872        Self {
873            region_id,
874            files: Arc::new(Mutex::new(HashMap::new())),
875            access_layer,
876            cache_manager,
877        }
878    }
879
880    /// Tracks newly finalized SSTs before they become visible in the manifest.
881    pub(crate) fn track(&self, ssts: &[SstInfo]) {
882        let mut files = self.files.lock().unwrap();
883        for sst in ssts {
884            files.insert(
885                sst.file_id,
886                (sst.index_metadata.version, sst.index_metadata.file_size > 0),
887            );
888        }
889    }
890
891    /// Disarms cleanup after the manifest edit has committed or may have committed.
892    ///
893    /// A manifest update error does not guarantee that the edit was not persisted. Once an edit
894    /// may become visible, its SSTs must be retained to avoid deleting referenced files.
895    pub(crate) fn disarm_cleanup(&self) {
896        self.files.lock().unwrap().clear();
897    }
898
899    #[cfg(test)]
900    pub(crate) fn num_tracked_files(&self) -> usize {
901        self.files.lock().unwrap().len()
902    }
903
904    /// Removes all finalized SSTs still owned by this job.
905    pub(crate) async fn cleanup(&self) {
906        if let Err(err) = self.try_cleanup().await {
907            error!(err; "Failed to clean uncommitted SSTs for region {}", self.region_id);
908        }
909    }
910
911    async fn try_cleanup(&self) -> crate::error::Result<()> {
912        let files = std::mem::take(&mut *self.files.lock().unwrap());
913        if files.is_empty() {
914            return Ok(());
915        }
916
917        let delete_index = files.values().any(|(_, exists_index)| *exists_index);
918        let file_ids = files
919            .into_iter()
920            .map(|(file_id, (index_version, _))| (file_id, index_version))
921            .collect::<Vec<_>>();
922        delete_files(
923            self.region_id,
924            &file_ids,
925            delete_index,
926            &self.access_layer,
927            &self.cache_manager,
928        )
929        .await
930    }
931
932    #[cfg(test)]
933    pub(crate) async fn cleanup_for_test(&self) -> crate::error::Result<()> {
934        self.try_cleanup().await
935    }
936}
937
938pub async fn delete_index(
939    region_index_id: RegionIndexId,
940    access_layer: &AccessLayerRef,
941    cache_manager: &Option<CacheManagerRef>,
942) -> crate::error::Result<()> {
943    delete_index_and_purge(region_index_id, access_layer, cache_manager).await?;
944
945    Ok(())
946}
947
948pub async fn delete_indexes(
949    index_ids: &[RegionIndexId],
950    access_layer: &AccessLayerRef,
951    cache_manager: &Option<CacheManagerRef>,
952) -> crate::error::Result<()> {
953    if index_ids.is_empty() {
954        return Ok(());
955    }
956
957    if let Err(e) = access_layer.delete_indexes(index_ids).await {
958        error!(e; "Failed to batch delete index files");
959
960        for index_id in index_ids {
961            delete_index_and_purge(*index_id, access_layer, cache_manager).await?;
962        }
963
964        return Ok(());
965    }
966
967    purge_indexes(index_ids, access_layer, cache_manager).await;
968
969    Ok(())
970}
971
972async fn delete_index_and_purge(
973    index_id: RegionIndexId,
974    access_layer: &AccessLayerRef,
975    cache_manager: &Option<CacheManagerRef>,
976) -> crate::error::Result<()> {
977    access_layer.delete_index(index_id).await?;
978    purge_index_cache_stager(
979        index_id.region_id(),
980        true,
981        access_layer,
982        cache_manager,
983        index_id.file_id(),
984        index_id.version,
985    )
986    .await;
987    Ok(())
988}
989
990async fn purge_indexes(
991    index_ids: &[RegionIndexId],
992    access_layer: &AccessLayerRef,
993    cache_manager: &Option<CacheManagerRef>,
994) {
995    for index_id in index_ids {
996        purge_index_cache_stager(
997            index_id.region_id(),
998            true,
999            access_layer,
1000            cache_manager,
1001            index_id.file_id(),
1002            index_id.version,
1003        )
1004        .await;
1005    }
1006}
1007
1008async fn purge_index_cache_stager(
1009    region_id: RegionId,
1010    delete_index: bool,
1011    access_layer: &AccessLayerRef,
1012    cache_manager: &Option<CacheManagerRef>,
1013    file_id: FileId,
1014    index_version: u64,
1015) {
1016    if let Some(write_cache) = cache_manager.as_ref().and_then(|cache| cache.write_cache()) {
1017        // Removes index file from the cache.
1018        if delete_index {
1019            write_cache
1020                .remove(IndexKey::new(
1021                    region_id,
1022                    file_id,
1023                    FileType::Puffin(index_version),
1024                ))
1025                .await;
1026        }
1027
1028        // Remove the SST file from the cache.
1029        write_cache
1030            .remove(IndexKey::new(region_id, file_id, FileType::Parquet))
1031            .await;
1032    }
1033
1034    // Purges index content in the stager.
1035    if let Err(e) = access_layer
1036        .puffin_manager_factory()
1037        .purge_stager(RegionIndexId::new(
1038            RegionFileId::new(region_id, file_id),
1039            index_version,
1040        ))
1041        .await
1042    {
1043        error!(e; "Failed to purge stager with index file, file_id: {}, index_version: {}, region: {}",
1044                file_id, index_version, region_id);
1045    }
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050    use std::str::FromStr;
1051
1052    use datatypes::prelude::ConcreteDataType;
1053    use datatypes::schema::{
1054        ColumnSchema, FulltextAnalyzer, FulltextBackend, FulltextOptions, SkippingIndexOptions,
1055    };
1056    use datatypes::value::Value;
1057    use partition::expr::{PartitionExpr, col};
1058
1059    use super::*;
1060
1061    fn create_file_meta(file_id: FileId, level: Level) -> FileMeta {
1062        FileMeta {
1063            region_id: 0.into(),
1064            file_id,
1065            time_range: FileTimeRange::default(),
1066            level,
1067            file_size: 0,
1068            max_row_group_uncompressed_size: 0,
1069            available_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
1070            indexes: vec![ColumnIndexMetadata {
1071                column_id: 0,
1072                created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
1073                ..Default::default()
1074            }],
1075            index_file_size: 0,
1076            index_version: 0,
1077            num_rows: 0,
1078            num_row_groups: 0,
1079            sequence: None,
1080            partition_expr: None,
1081            num_series: 0,
1082            ..Default::default()
1083        }
1084    }
1085
1086    #[test]
1087    fn test_deserialize_file_meta() {
1088        let file_meta = create_file_meta(FileId::random(), 0);
1089        let serialized_file_meta = serde_json::to_string(&file_meta).unwrap();
1090        let deserialized_file_meta = serde_json::from_str(&serialized_file_meta);
1091        assert_eq!(file_meta, deserialized_file_meta.unwrap());
1092    }
1093
1094    #[test]
1095    fn test_deserialize_from_string() {
1096        let json_file_meta = "{\"region_id\":0,\"file_id\":\"bc5896ec-e4d8-4017-a80d-f2de73188d55\",\
1097        \"time_range\":[{\"value\":0,\"unit\":\"Millisecond\"},{\"value\":0,\"unit\":\"Millisecond\"}],\
1098        \"available_indexes\":[\"InvertedIndex\"],\"indexes\":[{\"column_id\": 0, \"created_indexes\": [\"InvertedIndex\"]}],\"level\":0}";
1099        let file_meta = create_file_meta(
1100            FileId::from_str("bc5896ec-e4d8-4017-a80d-f2de73188d55").unwrap(),
1101            0,
1102        );
1103        let deserialized_file_meta: FileMeta = serde_json::from_str(json_file_meta).unwrap();
1104        assert_eq!(file_meta, deserialized_file_meta);
1105    }
1106
1107    #[test]
1108    fn test_deserialize_legacy_vector_index() {
1109        let json = r#"{
1110            "region_id": 0,
1111            "file_id": "bc5896ec-e4d8-4017-a80d-f2de73188d55",
1112            "time_range": [{"value":0,"unit":"Millisecond"},{"value":0,"unit":"Millisecond"}],
1113            "available_indexes": ["VectorIndex", "InvertedIndex"],
1114            "indexes": [{"column_id": 1, "created_indexes": ["VectorIndex"]}],
1115            "level": 0
1116        }"#;
1117        let meta: FileMeta = serde_json::from_str(json).unwrap();
1118        assert!(meta.inverted_index_available());
1119        assert!(!meta.fulltext_index_available());
1120        assert!(!meta.bloom_filter_index_available());
1121        let encoded = serde_json::to_value(&meta).unwrap();
1122        assert_eq!(
1123            encoded["available_indexes"],
1124            serde_json::json!(["VectorIndex", "InvertedIndex"])
1125        );
1126        assert_eq!(
1127            encoded["indexes"][0]["created_indexes"],
1128            serde_json::json!(["VectorIndex"])
1129        );
1130    }
1131
1132    #[test]
1133    fn test_file_meta_with_partition_expr() {
1134        let file_id = FileId::random();
1135        let partition_expr = PartitionExpr::new(
1136            col("a"),
1137            partition::expr::RestrictedOp::GtEq,
1138            Value::UInt32(10).into(),
1139        );
1140
1141        let file_meta_with_partition = FileMeta {
1142            region_id: 0.into(),
1143            file_id,
1144            time_range: FileTimeRange::default(),
1145            level: 0,
1146            file_size: 0,
1147            max_row_group_uncompressed_size: 0,
1148            available_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
1149            indexes: vec![ColumnIndexMetadata {
1150                column_id: 0,
1151                created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
1152                ..Default::default()
1153            }],
1154            index_file_size: 0,
1155            index_version: 0,
1156            num_rows: 0,
1157            num_row_groups: 0,
1158            sequence: None,
1159            partition_expr: Some(partition_expr.clone()),
1160            num_series: 0,
1161            ..Default::default()
1162        };
1163
1164        // Test serialization/deserialization
1165        let serialized = serde_json::to_string(&file_meta_with_partition).unwrap();
1166        let deserialized: FileMeta = serde_json::from_str(&serialized).unwrap();
1167        assert_eq!(file_meta_with_partition, deserialized);
1168
1169        // Verify the serialized JSON contains the expected partition expression string
1170        let serialized_value: serde_json::Value = serde_json::from_str(&serialized).unwrap();
1171        assert!(serialized_value["partition_expr"].as_str().is_some());
1172        let partition_expr_json = serialized_value["partition_expr"].as_str().unwrap();
1173        assert!(partition_expr_json.contains("\"Column\":\"a\""));
1174        assert!(partition_expr_json.contains("\"op\":\"GtEq\""));
1175
1176        // Test with None (legacy files)
1177        let file_meta_none = FileMeta {
1178            partition_expr: None,
1179            ..file_meta_with_partition.clone()
1180        };
1181        let serialized_none = serde_json::to_string(&file_meta_none).unwrap();
1182        let deserialized_none: FileMeta = serde_json::from_str(&serialized_none).unwrap();
1183        assert_eq!(file_meta_none, deserialized_none);
1184    }
1185
1186    #[test]
1187    fn test_file_meta_partition_expr_backward_compatibility() {
1188        // Test that we can deserialize old JSON format with partition_expr as string
1189        let json_with_partition_expr = r#"{
1190            "region_id": 0,
1191            "file_id": "bc5896ec-e4d8-4017-a80d-f2de73188d55",
1192            "time_range": [
1193                {"value": 0, "unit": "Millisecond"},
1194                {"value": 0, "unit": "Millisecond"}
1195            ],
1196            "level": 0,
1197            "file_size": 0,
1198            "available_indexes": ["InvertedIndex"],
1199            "index_file_size": 0,
1200            "num_rows": 0,
1201            "num_row_groups": 0,
1202            "sequence": null,
1203            "partition_expr": "{\"Expr\":{\"lhs\":{\"Column\":\"a\"},\"op\":\"GtEq\",\"rhs\":{\"Value\":{\"UInt32\":10}}}}"
1204        }"#;
1205
1206        let file_meta: FileMeta = serde_json::from_str(json_with_partition_expr).unwrap();
1207        assert!(file_meta.partition_expr.is_some());
1208        let expr = file_meta.partition_expr.unwrap();
1209        assert_eq!(format!("{}", expr), "a >= 10");
1210
1211        // Test empty partition expression string
1212        let json_with_empty_expr = r#"{
1213            "region_id": 0,
1214            "file_id": "bc5896ec-e4d8-4017-a80d-f2de73188d55",
1215            "time_range": [
1216                {"value": 0, "unit": "Millisecond"},
1217                {"value": 0, "unit": "Millisecond"}
1218            ],
1219            "level": 0,
1220            "file_size": 0,
1221            "available_indexes": [],
1222            "index_file_size": 0,
1223            "num_rows": 0,
1224            "num_row_groups": 0,
1225            "sequence": null,
1226            "partition_expr": ""
1227        }"#;
1228
1229        let file_meta_empty: FileMeta = serde_json::from_str(json_with_empty_expr).unwrap();
1230        assert!(file_meta_empty.partition_expr.is_none());
1231
1232        // Test null partition expression
1233        let json_with_null_expr = r#"{
1234            "region_id": 0,
1235            "file_id": "bc5896ec-e4d8-4017-a80d-f2de73188d55",
1236            "time_range": [
1237                {"value": 0, "unit": "Millisecond"},
1238                {"value": 0, "unit": "Millisecond"}
1239            ],
1240            "level": 0,
1241            "file_size": 0,
1242            "available_indexes": [],
1243            "index_file_size": 0,
1244            "num_rows": 0,
1245            "num_row_groups": 0,
1246            "sequence": null,
1247            "partition_expr": null
1248        }"#;
1249
1250        let file_meta_null: FileMeta = serde_json::from_str(json_with_null_expr).unwrap();
1251        assert!(file_meta_null.partition_expr.is_none());
1252
1253        // Test partition expression doesn't exist
1254        let json_with_empty_expr = r#"{
1255            "region_id": 0,
1256            "file_id": "bc5896ec-e4d8-4017-a80d-f2de73188d55",
1257            "time_range": [
1258                {"value": 0, "unit": "Millisecond"},
1259                {"value": 0, "unit": "Millisecond"}
1260            ],
1261            "level": 0,
1262            "file_size": 0,
1263            "available_indexes": [],
1264            "index_file_size": 0,
1265            "num_rows": 0,
1266            "num_row_groups": 0,
1267            "sequence": null
1268        }"#;
1269
1270        let file_meta_empty: FileMeta = serde_json::from_str(json_with_empty_expr).unwrap();
1271        assert!(file_meta_empty.partition_expr.is_none());
1272    }
1273
1274    #[test]
1275    fn test_file_meta_indexes_backward_compatibility() {
1276        // Old FileMeta format without the 'indexes' field
1277        let json_old_file_meta = r#"{
1278            "region_id": 0,
1279            "file_id": "bc5896ec-e4d8-4017-a80d-f2de73188d55",
1280            "time_range": [
1281                {"value": 0, "unit": "Millisecond"},
1282                {"value": 0, "unit": "Millisecond"}
1283            ],
1284            "available_indexes": ["InvertedIndex"],
1285            "level": 0,
1286            "file_size": 0,
1287            "index_file_size": 0,
1288            "num_rows": 0,
1289            "num_row_groups": 0
1290        }"#;
1291
1292        let deserialized_file_meta: FileMeta = serde_json::from_str(json_old_file_meta).unwrap();
1293
1294        // Verify backward compatibility: indexes field should default to empty vec
1295        assert_eq!(deserialized_file_meta.indexes, vec![]);
1296
1297        let expected_indexes: IndexTypes = SmallVec::from_iter([IndexType::InvertedIndex]);
1298        assert_eq!(deserialized_file_meta.available_indexes, expected_indexes);
1299
1300        assert_eq!(
1301            deserialized_file_meta.file_id,
1302            FileId::from_str("bc5896ec-e4d8-4017-a80d-f2de73188d55").unwrap()
1303        );
1304        assert!(!deserialized_file_meta.preserve_row_sequence);
1305    }
1306
1307    #[test]
1308    fn test_file_meta_preserve_row_sequence_serde() {
1309        let file_meta = FileMeta {
1310            preserve_row_sequence: true,
1311            ..Default::default()
1312        };
1313
1314        let serialized = serde_json::to_string(&file_meta).unwrap();
1315        let value: serde_json::Value = serde_json::from_str(&serialized).unwrap();
1316        assert_eq!(value["preserve_row_sequence"], true);
1317
1318        let deserialized: FileMeta = serde_json::from_str(&serialized).unwrap();
1319        assert_eq!(file_meta, deserialized);
1320
1321        let file_meta_false = FileMeta {
1322            preserve_row_sequence: false,
1323            ..file_meta.clone()
1324        };
1325        let serialized_false = serde_json::to_string(&file_meta_false).unwrap();
1326        let value_false: serde_json::Value = serde_json::from_str(&serialized_false).unwrap();
1327        assert!(value_false.get("preserve_row_sequence").is_none());
1328    }
1329    #[test]
1330    fn test_is_index_consistent_with_region() {
1331        fn new_column_meta(
1332            id: ColumnId,
1333            name: &str,
1334            inverted: bool,
1335            fulltext: bool,
1336            skipping: bool,
1337        ) -> ColumnMetadata {
1338            let mut column_schema =
1339                ColumnSchema::new(name, ConcreteDataType::string_datatype(), true);
1340            if inverted {
1341                column_schema = column_schema.with_inverted_index(true);
1342            }
1343            if fulltext {
1344                column_schema = column_schema
1345                    .with_fulltext_options(FulltextOptions::new_unchecked(
1346                        true,
1347                        FulltextAnalyzer::English,
1348                        false,
1349                        FulltextBackend::Bloom,
1350                        1000,
1351                        0.01,
1352                    ))
1353                    .unwrap();
1354            }
1355            if skipping {
1356                column_schema = column_schema
1357                    .with_skipping_options(SkippingIndexOptions::new_unchecked(
1358                        1024,
1359                        0.01,
1360                        datatypes::schema::SkippingIndexType::BloomFilter,
1361                    ))
1362                    .unwrap();
1363            }
1364
1365            ColumnMetadata {
1366                column_schema,
1367                semantic_type: api::v1::SemanticType::Tag,
1368                column_id: id,
1369            }
1370        }
1371
1372        // Case 1: Perfect match. File has exactly the required indexes.
1373        let mut file_meta = FileMeta {
1374            indexes: vec![ColumnIndexMetadata {
1375                column_id: 1,
1376                created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
1377                ..Default::default()
1378            }],
1379            ..Default::default()
1380        };
1381        let region_meta = vec![new_column_meta(1, "tag1", true, false, false)];
1382        assert!(file_meta.is_index_consistent_with_region(&region_meta));
1383
1384        // Case 2: Superset match. File has more indexes than required.
1385        file_meta.indexes = vec![ColumnIndexMetadata {
1386            column_id: 1,
1387            created_indexes: SmallVec::from_iter([
1388                IndexType::InvertedIndex,
1389                IndexType::BloomFilterIndex,
1390            ]),
1391            ..Default::default()
1392        }];
1393        let region_meta = vec![new_column_meta(1, "tag1", true, false, false)];
1394        assert!(file_meta.is_index_consistent_with_region(&region_meta));
1395
1396        // Case 3: Missing index type. File has the column but lacks the required index type.
1397        file_meta.indexes = vec![ColumnIndexMetadata {
1398            column_id: 1,
1399            created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
1400            ..Default::default()
1401        }];
1402        let region_meta = vec![new_column_meta(1, "tag1", true, true, false)]; // Requires fulltext too
1403        assert!(!file_meta.is_index_consistent_with_region(&region_meta));
1404
1405        // Case 4: Missing column. Region requires an index on a column not in the file's index list.
1406        file_meta.indexes = vec![ColumnIndexMetadata {
1407            column_id: 2, // File only has index for column 2
1408            created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
1409            ..Default::default()
1410        }];
1411        let region_meta = vec![new_column_meta(1, "tag1", true, false, false)]; // Requires index on column 1
1412        assert!(!file_meta.is_index_consistent_with_region(&region_meta));
1413
1414        // Case 5: No indexes required by region. Should always be consistent.
1415        file_meta.indexes = vec![ColumnIndexMetadata {
1416            column_id: 1,
1417            created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
1418            ..Default::default()
1419        }];
1420        let region_meta = vec![new_column_meta(1, "tag1", false, false, false)]; // No index required
1421        assert!(file_meta.is_index_consistent_with_region(&region_meta));
1422
1423        // Case 6: Empty file indexes. Region requires an index.
1424        file_meta.indexes = vec![];
1425        let region_meta = vec![new_column_meta(1, "tag1", true, false, false)];
1426        assert!(!file_meta.is_index_consistent_with_region(&region_meta));
1427
1428        // Case 7: Multiple columns, one is inconsistent.
1429        file_meta.indexes = vec![
1430            ColumnIndexMetadata {
1431                column_id: 1,
1432                created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
1433                ..Default::default()
1434            },
1435            ColumnIndexMetadata {
1436                column_id: 2, // Column 2 is missing the required BloomFilterIndex
1437                created_indexes: SmallVec::from_iter([IndexType::FulltextIndex]),
1438                ..Default::default()
1439            },
1440        ];
1441        let region_meta = vec![
1442            new_column_meta(1, "tag1", true, false, false),
1443            new_column_meta(2, "tag2", false, true, true), // Requires Fulltext and BloomFilter
1444        ];
1445        assert!(!file_meta.is_index_consistent_with_region(&region_meta));
1446    }
1447
1448    #[test]
1449    fn test_index_consistency_requires_current_fulltext_bloom_version() {
1450        let fulltext_column = |backend| {
1451            let column_schema = ColumnSchema::new("msg", ConcreteDataType::string_datatype(), true)
1452                .with_fulltext_options(FulltextOptions::new_unchecked(
1453                    true,
1454                    FulltextAnalyzer::English,
1455                    false,
1456                    backend,
1457                    1000,
1458                    0.01,
1459                ))
1460                .unwrap();
1461            vec![ColumnMetadata {
1462                column_schema,
1463                semantic_type: api::v1::SemanticType::Field,
1464                column_id: 1,
1465            }]
1466        };
1467        let file_meta = |fulltext_bloom_version| FileMeta {
1468            indexes: vec![ColumnIndexMetadata {
1469                column_id: 1,
1470                created_indexes: SmallVec::from_iter([IndexType::FulltextIndex]),
1471                fulltext_bloom_version,
1472            }],
1473            ..Default::default()
1474        };
1475
1476        let bloom = fulltext_column(FulltextBackend::Bloom);
1477        // Written before the version was recorded, i.e. with the old bloom blob.
1478        assert!(!file_meta(0).is_index_consistent_with_region(&bloom));
1479        assert!(file_meta(FULLTEXT_BLOOM_VERSION).is_index_consistent_with_region(&bloom));
1480        // Tantivy indexes carry no bloom version.
1481        let tantivy = fulltext_column(FulltextBackend::Tantivy);
1482        assert!(file_meta(0).is_index_consistent_with_region(&tantivy));
1483    }
1484}