Skip to main content

mito2/sst/
index.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
15pub(crate) mod bloom_filter;
16mod column;
17#[cfg(test)]
18mod column_test;
19pub(crate) mod fulltext_index;
20mod indexer;
21pub mod intermediate;
22pub(crate) mod inverted_index;
23mod primary_key;
24pub mod puffin_manager;
25#[cfg(test)]
26mod sparse_test;
27mod statistics;
28pub(crate) mod store;
29
30use std::cmp::Ordering;
31use std::collections::{HashMap, HashSet};
32use std::num::NonZeroUsize;
33use std::ops::ControlFlow;
34use std::sync::Arc;
35
36use arrow_schema::extension::ExtensionType;
37use bloom_filter::creator::BloomFilterIndexer;
38use bytes::Bytes;
39use common_telemetry::{debug, error, info, warn};
40use datatypes::arrow::record_batch::RecordBatch;
41use datatypes::extension::json::Json2ExtensionType;
42use index::target::IndexTarget;
43use mito_codec::row_converter::DensePrimaryKeyCodec;
44use object_store::ObjectStore;
45use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
46use puffin_manager::SstPuffinManager;
47use smallvec::SmallVec;
48use snafu::ResultExt;
49use statistics::{ByteCount, RowCount};
50use store_api::codec::PrimaryKeyEncoding;
51use store_api::metadata::RegionMetadataRef;
52use store_api::storage::{ColumnId, FileId, RegionId};
53use strum::IntoStaticStr;
54use tokio::sync::mpsc::Sender;
55
56use crate::access_layer::{AccessLayerRef, FilePathProvider, OperationType, RegionFilePathFactory};
57use crate::batch_size::{estimate_batch_size, largest_row_group_statistic};
58use crate::cache::file_cache::{FileCacheRef, FileType, IndexKey};
59use crate::cache::write_cache::{UploadOptions, UploadTracker, WriteCacheRef};
60use crate::cache::{CacheManagerRef, CacheStrategy};
61use crate::config::{BloomFilterConfig, FulltextIndexConfig, InvertedIndexConfig};
62use crate::error::{
63    BuildIndexAsyncSnafu, Error, JoinSnafu, RegionClosedSnafu, RegionDroppedSnafu,
64    RegionTruncatedSnafu, Result,
65};
66use crate::metrics::{
67    INDEX_ARTIFACT_CLEANUP_FAILURE_TOTAL, INDEX_CREATE_MEMORY_USAGE, INDEX_PUBLICATION_STALE_TOTAL,
68};
69use crate::read::Batch;
70use crate::region::options::IndexOptions;
71use crate::region::version::VersionControlRef;
72use crate::region::{
73    IndexBuildSource, IndexPublication, IndexPublicationStale, ManifestContextRef,
74};
75use crate::request::{
76    BackgroundNotify, BuildIndexRequest, IndexBuildFinished, IndexBuildStopped, WorkerRequest,
77    WorkerRequestWithTime,
78};
79use crate::schedule::scheduler::{Job, SchedulerRef};
80use crate::sst::file::{
81    ColumnIndexMetadata, FileHandle, IndexType, IndexTypes, RegionFileId, RegionIndexId,
82};
83use crate::sst::file_purger::FilePurgerRef;
84use crate::sst::index::fulltext_index::FULLTEXT_BLOOM_VERSION;
85use crate::sst::index::fulltext_index::creator::FulltextIndexer;
86use crate::sst::index::intermediate::IntermediateManager;
87use crate::sst::index::inverted_index::creator::InvertedIndexer;
88use crate::sst::parquet::SstInfo;
89use crate::worker::WorkerListener;
90
91pub(crate) const TYPE_INVERTED_INDEX: &str = "inverted_index";
92pub(crate) const TYPE_FULLTEXT_INDEX: &str = "fulltext_index";
93pub(crate) const TYPE_BLOOM_FILTER_INDEX: &str = "bloom_filter_index";
94
95/// Evicts local state for a stale index artifact.
96///
97/// The remote artifact may already be referenced by another region manifest
98/// because repartitioned regions share physical SST and index paths. With GC
99/// enabled, remote deletion therefore stays in the reference-aware GC lifecycle.
100pub(crate) async fn cleanup_stale_index_caches(
101    index_id: RegionIndexId,
102    access_layer: &AccessLayerRef,
103    cache_manager: Option<&CacheManagerRef>,
104    write_cache: Option<&WriteCacheRef>,
105) {
106    if let Some(cache_manager) = cache_manager {
107        CacheStrategy::EnableAll(cache_manager.clone())
108            .evict_puffin_cache(index_id)
109            .await;
110    }
111    if let Some(write_cache) = write_cache {
112        write_cache
113            .remove(IndexKey::new(
114                index_id.region_id(),
115                index_id.file_id(),
116                FileType::Puffin(index_id.version),
117            ))
118            .await;
119    }
120    if let Err(e) = access_layer
121        .puffin_manager_factory()
122        .purge_stager(index_id)
123        .await
124    {
125        INDEX_ARTIFACT_CLEANUP_FAILURE_TOTAL.inc();
126        warn!(
127            e;
128            "Failed to purge stale index artifact from stager, region: {}, file_id: {}, index_version: {}",
129            index_id.region_id(),
130            index_id.file_id(),
131            index_id.version
132        );
133    }
134}
135
136/// Triggers background download of an index file to the local cache.
137pub(crate) fn trigger_index_background_download(
138    file_cache: Option<&FileCacheRef>,
139    file_id: &RegionIndexId,
140    file_size_hint: Option<u64>,
141    path_factory: &RegionFilePathFactory,
142    object_store: &ObjectStore,
143) {
144    if let (Some(file_cache), Some(file_size)) = (file_cache, file_size_hint) {
145        let index_key = IndexKey::new(
146            file_id.region_id(),
147            file_id.file_id(),
148            FileType::Puffin(file_id.version),
149        );
150        let remote_path = path_factory.build_index_file_path(file_id.file_id);
151        file_cache.maybe_download_background(
152            index_key,
153            remote_path,
154            object_store.clone(),
155            file_size,
156        );
157    }
158}
159
160/// Output of the index creation.
161#[derive(Debug, Clone, Default)]
162pub struct IndexOutput {
163    /// Size of the file.
164    pub file_size: u64,
165    /// Index version.
166    pub version: u64,
167    /// Inverted index output.
168    pub inverted_index: InvertedIndexOutput,
169    /// Fulltext index output.
170    pub fulltext_index: FulltextIndexOutput,
171    /// Columns of `fulltext_index` that use the bloom backend.
172    pub fulltext_bloom_columns: Vec<ColumnId>,
173    /// Bloom filter output.
174    pub bloom_filter: BloomFilterOutput,
175}
176
177impl IndexOutput {
178    pub fn build_available_indexes(&self) -> SmallVec<[IndexType; 4]> {
179        let mut indexes = SmallVec::new();
180        if self.inverted_index.is_available() {
181            indexes.push(IndexType::InvertedIndex);
182        }
183        if self.fulltext_index.is_available() {
184            indexes.push(IndexType::FulltextIndex);
185        }
186        if self.bloom_filter.is_available() {
187            indexes.push(IndexType::BloomFilterIndex);
188        }
189        indexes
190    }
191
192    pub fn build_indexes(&self) -> Vec<ColumnIndexMetadata> {
193        let mut map: HashMap<ColumnId, IndexTypes> = HashMap::new();
194
195        if self.inverted_index.is_available() {
196            for &col in &self.inverted_index.columns {
197                map.entry(col).or_default().push(IndexType::InvertedIndex);
198            }
199        }
200        if self.fulltext_index.is_available() {
201            for &col in &self.fulltext_index.columns {
202                map.entry(col).or_default().push(IndexType::FulltextIndex);
203            }
204        }
205        if self.bloom_filter.is_available() {
206            for &col in &self.bloom_filter.columns {
207                map.entry(col)
208                    .or_default()
209                    .push(IndexType::BloomFilterIndex);
210            }
211        }
212
213        map.into_iter()
214            .map(|(column_id, created_indexes)| ColumnIndexMetadata {
215                column_id,
216                fulltext_bloom_version: if self.fulltext_index.is_available()
217                    && self.fulltext_bloom_columns.contains(&column_id)
218                {
219                    FULLTEXT_BLOOM_VERSION
220                } else {
221                    0
222                },
223                created_indexes,
224            })
225            .collect::<Vec<_>>()
226    }
227}
228
229/// Base output of the index creation.
230#[derive(Debug, Clone, Default)]
231pub struct IndexBaseOutput {
232    /// Size of the index.
233    pub index_size: ByteCount,
234    /// Number of rows in the index.
235    pub row_count: RowCount,
236    /// Available columns in the index.
237    pub columns: Vec<ColumnId>,
238}
239
240impl IndexBaseOutput {
241    pub fn is_available(&self) -> bool {
242        self.index_size > 0
243    }
244}
245
246/// Output of the inverted index creation.
247pub type InvertedIndexOutput = IndexBaseOutput;
248/// Output of the fulltext index creation.
249pub type FulltextIndexOutput = IndexBaseOutput;
250/// Output of the bloom filter creation.
251pub type BloomFilterOutput = IndexBaseOutput;
252
253/// The index creator that hides the error handling details.
254#[derive(Default)]
255pub struct Indexer {
256    file_id: FileId,
257    /// The logical region used for metadata and intermediate index files.
258    region_id: RegionId,
259    /// The region that owns the physical SST and Puffin files.
260    physical_region_id: RegionId,
261    index_version: u64,
262    puffin_manager: Option<SstPuffinManager>,
263    write_cache_enabled: bool,
264    inverted_indexer: Option<InvertedIndexer>,
265    last_mem_inverted_index: usize,
266    fulltext_indexer: Option<FulltextIndexer>,
267    last_mem_fulltext_index: usize,
268    bloom_filter_indexer: Option<BloomFilterIndexer>,
269    last_mem_bloom_filter: usize,
270    /// Present only when the active creators together need every Dense PK field.
271    dense_pk_decoder: Option<DensePrimaryKeyCodec>,
272    intermediate_manager: Option<IntermediateManager>,
273}
274
275impl Indexer {
276    /// Returns true if no index creator is active.
277    pub(crate) fn is_empty(&self) -> bool {
278        self.inverted_indexer.is_none()
279            && self.fulltext_indexer.is_none()
280            && self.bloom_filter_indexer.is_none()
281    }
282
283    /// Wraps real creators for update-only benchmarks without a Puffin output.
284    #[cfg(feature = "testing")]
285    pub fn for_bench(
286        metadata: &RegionMetadataRef,
287        inverted_indexer: Option<InvertedIndexer>,
288        bloom_filter_indexer: Option<BloomFilterIndexer>,
289    ) -> Self {
290        let mut indexer = Self {
291            region_id: metadata.region_id,
292            inverted_indexer,
293            bloom_filter_indexer,
294            ..Default::default()
295        };
296        indexer.prepare_dense_pk_decoder(metadata);
297        indexer
298    }
299
300    /// Computes demand once from active creators. Field columns and duplicate
301    /// requests across creators cannot turn a partial PK projection into a full one.
302    fn prepare_dense_pk_decoder(&mut self, metadata: &RegionMetadataRef) {
303        if metadata.primary_key_encoding != PrimaryKeyEncoding::Dense
304            || metadata.primary_key.is_empty()
305        {
306            return;
307        }
308        let requested: HashSet<_> = self
309            .inverted_indexer
310            .iter()
311            .flat_map(|indexer| indexer.indexed_targets())
312            .chain(
313                self.bloom_filter_indexer
314                    .iter()
315                    .flat_map(|indexer| indexer.indexed_targets()),
316            )
317            .filter_map(|target| match target {
318                IndexTarget::ColumnId(id) => Some(*id),
319                IndexTarget::JsonPath(_) => None,
320            })
321            .collect();
322        if metadata.primary_key.iter().all(|id| requested.contains(id)) {
323            self.dense_pk_decoder = Some(DensePrimaryKeyCodec::new(metadata));
324        }
325    }
326
327    /// Called before any creator consumes the batch, so the full decode is shared.
328    fn prepare_primary_key(&self, batch: &mut Batch) -> Result<()> {
329        if let Some(codec) = &self.dense_pk_decoder {
330            batch.ensure_dense_pk_decoded(codec)?;
331        }
332        Ok(())
333    }
334
335    /// Updates the index with the given batch.
336    pub async fn update(&mut self, batch: &mut Batch) {
337        self.do_update(batch).await;
338
339        self.flush_mem_metrics();
340    }
341
342    /// Updates the index with the given flat format RecordBatch.
343    pub async fn update_flat(&mut self, batch: &RecordBatch) {
344        self.do_update_flat(batch).await;
345
346        self.flush_mem_metrics();
347    }
348
349    /// Finalizes the index creation.
350    pub async fn finish(&mut self) -> IndexOutput {
351        let output = self.do_finish().await;
352
353        self.flush_mem_metrics();
354        output
355    }
356
357    /// Aborts the index creation.
358    pub async fn abort(&mut self) {
359        self.do_abort().await;
360
361        self.flush_mem_metrics();
362    }
363
364    fn flush_mem_metrics(&mut self) {
365        let inverted_mem = self
366            .inverted_indexer
367            .as_ref()
368            .map_or(0, |creator| creator.memory_usage());
369        INDEX_CREATE_MEMORY_USAGE
370            .with_label_values(&[TYPE_INVERTED_INDEX])
371            .add(inverted_mem as i64 - self.last_mem_inverted_index as i64);
372        self.last_mem_inverted_index = inverted_mem;
373
374        let fulltext_mem = self
375            .fulltext_indexer
376            .as_ref()
377            .map_or(0, |creator| creator.memory_usage());
378        INDEX_CREATE_MEMORY_USAGE
379            .with_label_values(&[TYPE_FULLTEXT_INDEX])
380            .add(fulltext_mem as i64 - self.last_mem_fulltext_index as i64);
381        self.last_mem_fulltext_index = fulltext_mem;
382
383        let bloom_filter_mem = self
384            .bloom_filter_indexer
385            .as_ref()
386            .map_or(0, |creator| creator.memory_usage());
387        INDEX_CREATE_MEMORY_USAGE
388            .with_label_values(&[TYPE_BLOOM_FILTER_INDEX])
389            .add(bloom_filter_mem as i64 - self.last_mem_bloom_filter as i64);
390        self.last_mem_bloom_filter = bloom_filter_mem;
391    }
392}
393
394#[async_trait::async_trait]
395pub trait IndexerBuilder {
396    /// Builds an indexer for the physical SST file.
397    async fn build(
398        &self,
399        region_file_id: RegionFileId,
400        index_version: u64,
401        row_group_size: Option<usize>,
402    ) -> Indexer;
403}
404#[derive(Clone)]
405pub(crate) struct IndexerBuilderImpl {
406    pub(crate) build_type: IndexBuildType,
407    pub(crate) metadata: RegionMetadataRef,
408    pub(crate) puffin_manager: SstPuffinManager,
409    pub(crate) write_cache_enabled: bool,
410    pub(crate) intermediate_manager: IntermediateManager,
411    pub(crate) index_options: IndexOptions,
412    pub(crate) inverted_index_config: InvertedIndexConfig,
413    pub(crate) fulltext_index_config: FulltextIndexConfig,
414    pub(crate) bloom_filter_index_config: BloomFilterConfig,
415}
416
417#[async_trait::async_trait]
418impl IndexerBuilder for IndexerBuilderImpl {
419    /// Sanity check for arguments and create a new [Indexer] if arguments are valid.
420    async fn build(
421        &self,
422        region_file_id: RegionFileId,
423        index_version: u64,
424        row_group_size: Option<usize>,
425    ) -> Indexer {
426        let mut indexer = Indexer {
427            file_id: region_file_id.file_id(),
428            region_id: self.metadata.region_id,
429            physical_region_id: region_file_id.region_id(),
430            index_version,
431            write_cache_enabled: self.write_cache_enabled,
432            ..Default::default()
433        };
434
435        indexer.inverted_indexer =
436            self.build_inverted_indexer(region_file_id.file_id(), row_group_size);
437        indexer.fulltext_indexer = self.build_fulltext_indexer(region_file_id.file_id()).await;
438        indexer.bloom_filter_indexer = self.build_bloom_filter_indexer(region_file_id.file_id());
439        indexer.prepare_dense_pk_decoder(&self.metadata);
440        indexer.intermediate_manager = Some(self.intermediate_manager.clone());
441
442        if indexer.is_empty() {
443            indexer.abort().await;
444            return Indexer::default();
445        }
446
447        indexer.puffin_manager = Some(self.puffin_manager.clone());
448        indexer
449    }
450}
451
452/// Builds the indexes of an SST from its encoded parquet bytes.
453///
454/// Encoded bulk parts are flushed without passing through [ParquetWriter], so their indexes
455/// are built from the bytes still in memory instead of reading the uploaded SST back. Like
456/// the writer, a failure only drops the index and leaves the SST unindexed.
457///
458/// [ParquetWriter]: crate::sst::parquet::writer::ParquetWriter
459pub(crate) async fn build_index_from_parquet_bytes(
460    builder: &IndexerBuilderImpl,
461    region_file_id: RegionFileId,
462    data: &Bytes,
463) -> IndexOutput {
464    let reader_builder = match ParquetRecordBatchReaderBuilder::try_new(data.clone()) {
465        Ok(reader_builder) => reader_builder,
466        Err(e) => {
467            warn!(e; "Failed to read encoded SST {:?} for index creation", region_file_id);
468            return IndexOutput::default();
469        }
470    };
471    let row_group_size = reader_builder
472        .metadata()
473        .row_groups()
474        .first()
475        .map(|row_group| row_group.num_rows() as usize)
476        .filter(|size| *size > 0);
477    let mut indexer = builder.build(region_file_id, 0, row_group_size).await;
478    if indexer.is_empty() {
479        return IndexOutput::default();
480    }
481    // Follow the batch size the flush reads use, so wide rows are not decoded 8192 at a time.
482    let batch_size = estimate_batch_size(largest_row_group_statistic(reader_builder.metadata()));
483
484    // TODO(dennis): decode only the columns the enabled indexers need, including the encoded
485    // primary key for tag indexes. This needs the indexer to stop locating columns by position.
486    let mut reader = match reader_builder.with_batch_size(batch_size).build() {
487        Ok(reader) => reader,
488        Err(e) => {
489            warn!(e; "Failed to read encoded SST {:?} for index creation", region_file_id);
490            indexer.abort().await;
491            return IndexOutput::default();
492        }
493    };
494    loop {
495        // Decoding is CPU-bound, so each batch is decoded on the blocking pool. Only one batch
496        // is in flight, and an aborted flush stops after the batch being decoded.
497        let decoded = common_runtime::spawn_blocking_global(move || {
498            let batch = reader.next();
499            (reader, batch)
500        })
501        .await;
502        let batch = match decoded {
503            Ok((next_reader, batch)) => {
504                reader = next_reader;
505                batch
506            }
507            Err(e) => {
508                warn!(e; "Failed to decode encoded SST {:?} for index creation", region_file_id);
509                indexer.abort().await;
510                return IndexOutput::default();
511            }
512        };
513        match batch {
514            Some(Ok(batch)) => indexer.update_flat(&batch).await,
515            Some(Err(e)) => {
516                warn!(e; "Failed to decode encoded SST {:?} for index creation", region_file_id);
517                indexer.abort().await;
518                return IndexOutput::default();
519            }
520            None => break,
521        }
522    }
523    indexer.finish().await
524}
525
526impl IndexerBuilderImpl {
527    fn build_inverted_indexer(
528        &self,
529        file_id: FileId,
530        row_group_size: Option<usize>,
531    ) -> Option<InvertedIndexer> {
532        let create = match self.build_type {
533            IndexBuildType::Flush => self.inverted_index_config.create_on_flush.auto(),
534            IndexBuildType::Compact => self.inverted_index_config.create_on_compaction.auto(),
535            _ => true,
536        };
537
538        if !create {
539            debug!(
540                "Skip creating inverted index due to config, region_id: {}, file_id: {}",
541                self.metadata.region_id, file_id,
542            );
543            return None;
544        }
545
546        let indexed_column_ids = self.metadata.inverted_indexed_column_ids(
547            self.index_options.inverted_index.ignore_column_ids.iter(),
548        );
549        let mut index_targets: Vec<_> = indexed_column_ids
550            .into_iter()
551            .map(IndexTarget::ColumnId)
552            .collect();
553        index_targets.extend(self.collect_json_inverted_index_targets());
554
555        if index_targets.is_empty() {
556            debug!(
557                "No columns to be indexed, skip creating inverted index, region_id: {}, file_id: {}",
558                self.metadata.region_id, file_id,
559            );
560            return None;
561        }
562
563        let Some(mut segment_row_count) =
564            NonZeroUsize::new(self.index_options.inverted_index.segment_row_count)
565        else {
566            warn!(
567                "Segment row count is 0, skip creating index, region_id: {}, file_id: {}",
568                self.metadata.region_id, file_id,
569            );
570            return None;
571        };
572
573        // if segment row count not aligned with row group size, adjust it to be aligned.
574        if let Some(row_group_size) = row_group_size.and_then(NonZeroUsize::new)
575            && row_group_size.get() % segment_row_count.get() != 0
576        {
577            segment_row_count = row_group_size;
578        }
579
580        let indexer = InvertedIndexer::new(
581            file_id,
582            &self.metadata,
583            self.intermediate_manager.clone(),
584            self.inverted_index_config.mem_threshold_on_create(),
585            segment_row_count,
586            index_targets,
587        );
588
589        Some(indexer)
590    }
591
592    /// Collects inverted index targets for JSON type hints with indexing enabled.
593    ///
594    /// Only flush and compaction builds collect these targets; manual and schema-change
595    /// rebuilds return no JSON targets.
596    fn collect_json_inverted_index_targets(&self) -> Vec<IndexTarget> {
597        // FIXME: Rebuilds replace the whole Puffin file and can drop existing JSON path indexes.
598        if !matches!(
599            self.build_type,
600            IndexBuildType::Flush | IndexBuildType::Compact
601        ) {
602            return Vec::new();
603        }
604
605        let mut index_targets = Vec::new();
606        for col in &self.metadata.column_metadatas {
607            if !col.column_schema.data_type.is_json2() {
608                continue;
609            }
610            let Ok(Some(extension)) = col.column_schema.extension_type::<Json2ExtensionType>()
611            else {
612                continue;
613            };
614            for hint in extension.metadata().json_settings().type_hints() {
615                if !hint.inverted_index {
616                    continue;
617                }
618                // JSON hints may also describe containers through non-SQL ingestion.
619                if !(hint.data_type.is_numeric()
620                    || hint.data_type.is_boolean()
621                    || hint.data_type.is_string())
622                {
623                    continue;
624                }
625                match IndexTarget::new_json_path(col.column_id, hint.path.clone()) {
626                    Ok(target) => index_targets.push(target),
627                    Err(err) => warn!(err; "Skipping invalid JSON inverted index target"),
628                }
629            }
630        }
631
632        index_targets
633    }
634
635    async fn build_fulltext_indexer(&self, file_id: FileId) -> Option<FulltextIndexer> {
636        let create = match self.build_type {
637            IndexBuildType::Flush => self.fulltext_index_config.create_on_flush.auto(),
638            IndexBuildType::Compact => self.fulltext_index_config.create_on_compaction.auto(),
639            _ => true,
640        };
641
642        if !create {
643            debug!(
644                "Skip creating full-text index due to config, region_id: {}, file_id: {}",
645                self.metadata.region_id, file_id,
646            );
647            return None;
648        }
649
650        let mem_limit = self.fulltext_index_config.mem_threshold_on_create();
651        let creator = FulltextIndexer::new(
652            &self.metadata.region_id,
653            &file_id,
654            &self.intermediate_manager,
655            &self.metadata,
656            self.fulltext_index_config.compress,
657            mem_limit,
658            self.build_type.clone(),
659        )
660        .await;
661
662        let err = match creator {
663            Ok(creator) => {
664                if creator.is_none() {
665                    debug!(
666                        "Skip creating full-text index due to no columns require indexing, region_id: {}, file_id: {}",
667                        self.metadata.region_id, file_id,
668                    );
669                }
670                return creator;
671            }
672            Err(err) => err,
673        };
674
675        if cfg!(any(test, feature = "test")) {
676            panic!(
677                "Failed to create full-text indexer, region_id: {}, file_id: {}, err: {:?}",
678                self.metadata.region_id, file_id, err
679            );
680        } else {
681            warn!(
682                err; "Failed to create full-text indexer, region_id: {}, file_id: {}",
683                self.metadata.region_id, file_id,
684            );
685        }
686
687        None
688    }
689
690    fn build_bloom_filter_indexer(&self, file_id: FileId) -> Option<BloomFilterIndexer> {
691        let create = match self.build_type {
692            IndexBuildType::Flush => self.bloom_filter_index_config.create_on_flush.auto(),
693            IndexBuildType::Compact => self.bloom_filter_index_config.create_on_compaction.auto(),
694            _ => true,
695        };
696
697        if !create {
698            debug!(
699                "Skip creating bloom filter due to config, region_id: {}, file_id: {}",
700                self.metadata.region_id, file_id,
701            );
702            return None;
703        }
704
705        let mem_limit = self.bloom_filter_index_config.mem_threshold_on_create();
706        let indexer = BloomFilterIndexer::new(
707            file_id,
708            &self.metadata,
709            self.intermediate_manager.clone(),
710            mem_limit,
711            self.build_type.clone(),
712        );
713
714        let err = match indexer {
715            Ok(indexer) => {
716                if indexer.is_none() {
717                    debug!(
718                        "Skip creating bloom filter due to no columns require indexing, region_id: {}, file_id: {}",
719                        self.metadata.region_id, file_id,
720                    );
721                }
722                return indexer;
723            }
724            Err(err) => err,
725        };
726
727        if cfg!(any(test, feature = "test")) {
728            panic!(
729                "Failed to create bloom filter, region_id: {}, file_id: {}, err: {:?}",
730                self.metadata.region_id, file_id, err
731            );
732        } else {
733            warn!(
734                err; "Failed to create bloom filter, region_id: {}, file_id: {}",
735                self.metadata.region_id, file_id,
736            );
737        }
738
739        None
740    }
741}
742
743/// Type of an index build task.
744#[derive(Debug, Clone, IntoStaticStr, PartialEq)]
745pub enum IndexBuildType {
746    /// Build index when schema change.
747    SchemaChange,
748    /// Create or update index after flush.
749    Flush,
750    /// Create or update index after compact.
751    Compact,
752    /// Manually build index.
753    Manual,
754}
755
756impl IndexBuildType {
757    fn as_str(&self) -> &'static str {
758        self.into()
759    }
760
761    // Higher value means higher priority.
762    fn priority(&self) -> u8 {
763        match self {
764            IndexBuildType::Manual => 3,
765            IndexBuildType::SchemaChange => 2,
766            IndexBuildType::Flush => 1,
767            IndexBuildType::Compact => 0,
768        }
769    }
770}
771
772impl From<OperationType> for IndexBuildType {
773    fn from(op_type: OperationType) -> Self {
774        match op_type {
775            OperationType::Flush => IndexBuildType::Flush,
776            OperationType::Compact => IndexBuildType::Compact,
777        }
778    }
779}
780
781/// Outcome of an index build task.
782#[derive(Debug, Clone, PartialEq, Eq, Hash)]
783pub enum IndexBuildOutcome {
784    Finished,
785    Aborted(String),
786}
787
788/// How an index build task reports its result.
789enum IndexBuildReport {
790    /// The task sends the outcome to its waiters.
791    Done(IndexBuildOutcome),
792    /// The worker or a retry task owns the waiters, so the task must not
793    /// report to them.
794    Deferred,
795}
796
797/// Mpsc output result sender.
798pub type ResultMpscSender = Sender<Result<IndexBuildOutcome>>;
799
800#[derive(Clone)]
801pub struct IndexBuildTask {
802    /// The logical region whose manifest and in-memory version this task updates.
803    pub region_id: RegionId,
804    /// The SST file handle to build index for.
805    pub file: FileHandle,
806    /// The target region metadata used to decode rows from the SST.
807    ///
808    /// An SST may originate in another region while being visible in the target
809    /// manifest. This metadata defines the target schema and sequence domain;
810    /// applying the staging manifest only makes imported files visible. Index
811    /// rebuild happens later when a flush, compaction, schema change, or manual
812    /// index build request schedules it.
813    pub(crate) target_region_metadata: RegionMetadataRef,
814    /// The manifest state this build is based on.
815    pub(crate) source: IndexBuildSource,
816    /// Index options of the target region when the task was created.
817    pub(crate) index_options: IndexOptions,
818    pub reason: IndexBuildType,
819    pub access_layer: AccessLayerRef,
820    pub(crate) listener: WorkerListener,
821    pub(crate) manifest_ctx: ManifestContextRef,
822    pub write_cache: Option<WriteCacheRef>,
823    pub cache_manager: Option<CacheManagerRef>,
824    pub file_purger: FilePurgerRef,
825    /// When write cache is enabled, the indexer builder should be built from the write cache.
826    /// Otherwise, it should be built from the access layer.
827    pub indexer_builder: Arc<dyn IndexerBuilder + Send + Sync>,
828    /// Request sender to notify the region worker.
829    pub(crate) request_sender: Sender<WorkerRequestWithTime>,
830    /// Senders of callers waiting for this build. Coalesced tasks and
831    /// schema-change retries hand their senders over to the surviving task.
832    pub(crate) result_senders: Vec<ResultMpscSender>,
833}
834
835impl std::fmt::Debug for IndexBuildTask {
836    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
837        f.debug_struct("IndexBuildTask")
838            .field("region_id", &self.region_id)
839            .field("origin_region_id", &self.source.file_meta.region_id)
840            .field("file_id", &self.source.file_meta.file_id)
841            .field("schema_version", &self.source.schema_version)
842            .field("reason", &self.reason)
843            .finish()
844    }
845}
846
847impl IndexBuildTask {
848    /// Notify the caller the job is success.
849    pub async fn on_success(&self, outcome: IndexBuildOutcome) {
850        for sender in &self.result_senders {
851            let _ = sender.send(Ok(outcome.clone())).await;
852        }
853    }
854
855    /// Send index build error to waiter.
856    pub async fn on_failure(&self, err: Arc<Error>) {
857        for sender in &self.result_senders {
858            let _ = sender
859                .send(Err(err.clone()).context(BuildIndexAsyncSnafu {
860                    region_id: self.region_id,
861                }))
862                .await;
863        }
864    }
865
866    fn into_index_build_job(mut self, version_control: VersionControlRef) -> Job {
867        Box::pin(async move {
868            self.do_index_build(version_control).await;
869        })
870    }
871
872    async fn do_index_build(&mut self, version_control: VersionControlRef) {
873        self.listener
874            .on_index_build_begin(self.region_file_id())
875            .await;
876        let result = if self.reason == IndexBuildType::Compact {
877            let mut task = self.clone();
878            // Keep the scheduler slot occupied until the compact runtime finishes the build.
879            match common_runtime::spawn_compact(
880                async move { task.index_build(version_control).await },
881            )
882            .await
883            {
884                Ok(result) => result,
885                Err(err) => Err(err).context(JoinSnafu),
886            }
887        } else {
888            self.index_build(version_control).await
889        };
890        match result {
891            Ok(IndexBuildReport::Done(outcome)) => {
892                if let IndexBuildOutcome::Aborted(reason) = &outcome {
893                    debug!("Index build aborted: {}", reason);
894                }
895                self.on_success(outcome).await
896            }
897            Ok(IndexBuildReport::Deferred) => {}
898            Err(e) => {
899                warn!(
900                    e; "Index build task failed, region: {}, file_id: {}",
901                    self.region_id, self.source.file_meta.file_id,
902                );
903                self.on_failure(e.into()).await
904            }
905        }
906        self.notify_worker(BackgroundNotify::IndexBuildStopped(IndexBuildStopped {
907            file_id: self.source.file_meta.file_id,
908        }))
909        .await;
910    }
911
912    fn region_file_id(&self) -> RegionFileId {
913        RegionFileId::new(
914            self.source.file_meta.region_id,
915            self.source.file_meta.file_id,
916        )
917    }
918
919    async fn notify_worker(&self, notify: BackgroundNotify) {
920        let worker_request = WorkerRequest::Background {
921            region_id: self.region_id,
922            notify,
923        };
924        let _ = self
925            .request_sender
926            .send(WorkerRequestWithTime::new(worker_request))
927            .await;
928    }
929
930    /// Builds an aborted outcome without a listener event. Builds that create
931    /// no index stop here, and the listener observes them as neither finished
932    /// nor aborted.
933    fn aborted(&self, reason: &str) -> IndexBuildReport {
934        IndexBuildReport::Done(IndexBuildOutcome::Aborted(format!(
935            "{reason}, region: {}, file_id: {}",
936            self.region_id, self.source.file_meta.file_id
937        )))
938    }
939
940    async fn abort_and_notify(
941        &self,
942        region_file_id: RegionFileId,
943        reason: &str,
944    ) -> IndexBuildReport {
945        self.listener.on_index_build_abort(region_file_id).await;
946        self.aborted(reason)
947    }
948
949    // Checks if the SST file still exists in object store and version to avoid conflict with compaction.
950    async fn check_sst_file_exists(&self, version_control: &VersionControlRef) -> bool {
951        let file_id = self.source.file_meta.file_id;
952        let level = self.source.file_meta.level;
953        // We should check current version instead of the version when the job is created.
954        let version = version_control.current().version;
955
956        let Some(level_files) = version.ssts.levels().get(level as usize) else {
957            warn!(
958                "File id {} not found in level {} for index build, region: {}",
959                file_id, level, self.region_id
960            );
961            return false;
962        };
963
964        match level_files.files.get(&file_id) {
965            Some(handle) if !handle.is_deleted() && !handle.compacting() => {
966                // If the file's metadata is present in the current version, the physical SST file
967                // is guaranteed to exist on object store. The file purger removes the physical
968                // file only after its metadata is removed from the version.
969                true
970            }
971            _ => {
972                warn!(
973                    "File id {} not found in region version for index build, region: {}",
974                    file_id, self.region_id
975                );
976                false
977            }
978        }
979    }
980
981    async fn index_build(
982        &mut self,
983        version_control: VersionControlRef,
984    ) -> Result<IndexBuildReport> {
985        // Taken before `prepare_source` replaces the source, and used for the
986        // index files and listener events of this build.
987        let region_file_id = self.region_file_id();
988        if let ControlFlow::Break(report) =
989            self.prepare_source(region_file_id, &version_control).await
990        {
991            return Ok(report);
992        }
993        let new_index_version = self
994            .source
995            .file_meta
996            .index_version()
997            .map_or(0, |version| version + 1);
998        let (mut indexer, index_output) =
999            match self.create_index(region_file_id, new_index_version).await? {
1000                ControlFlow::Continue(created) => created,
1001                ControlFlow::Break(report) => return Ok(report),
1002            };
1003
1004        // Check SST file existence again after building index.
1005        if !self.check_sst_file_exists(&version_control).await {
1006            // Calls abort to clean up index files.
1007            indexer.abort().await;
1008            return Ok(self
1009                .abort_and_notify(region_file_id, "SST file not found during index build")
1010                .await);
1011        }
1012
1013        self.publish(region_file_id, index_output, new_index_version)
1014            .await
1015    }
1016
1017    /// Checks that the SST still exists and moves the build source to the
1018    /// file's current manifest state.
1019    async fn prepare_source(
1020        &mut self,
1021        region_file_id: RegionFileId,
1022        version_control: &VersionControlRef,
1023    ) -> ControlFlow<IndexBuildReport> {
1024        // Check SST file existence before building index to avoid failure of parquet reader.
1025        if !self.check_sst_file_exists(version_control).await {
1026            return ControlFlow::Break(
1027                self.abort_and_notify(region_file_id, "SST file not found during index build")
1028                    .await,
1029            );
1030        }
1031
1032        // A task may have waited behind another build of the same SST. Build on
1033        // the index that build published, otherwise this publication is stale.
1034        let current_file_meta = self
1035            .manifest_ctx
1036            .manifest()
1037            .await
1038            .files
1039            .get(&self.source.file_meta.file_id)
1040            .cloned();
1041        let Some(current_file_meta) = current_file_meta else {
1042            return ControlFlow::Break(
1043                self.abort_and_notify(
1044                    region_file_id,
1045                    "SST file not found in manifest during index build",
1046                )
1047                .await,
1048            );
1049        };
1050        if self.reason == IndexBuildType::Manual
1051            && current_file_meta.is_index_consistent_with_region(
1052                &self.target_region_metadata.column_metadatas,
1053                &self.index_options,
1054            )
1055        {
1056            return ControlFlow::Break(
1057                self.abort_and_notify(region_file_id, "Index is already up to date")
1058                    .await,
1059            );
1060        }
1061        self.source.file_meta = current_file_meta;
1062        ControlFlow::Continue(())
1063    }
1064
1065    /// Reads the SST and creates the index files.
1066    async fn create_index(
1067        &self,
1068        region_file_id: RegionFileId,
1069        new_index_version: u64,
1070    ) -> Result<ControlFlow<IndexBuildReport, (Indexer, IndexOutput)>> {
1071        let mut parquet_reader = self
1072            .access_layer
1073            .read_sst(self.file.clone()) // use the latest file handle instead of creating a new one
1074            .expected_metadata(Some(self.target_region_metadata.clone()))
1075            .build()
1076            .await?;
1077
1078        let row_group_size = parquet_reader.as_ref().and_then(|reader| {
1079            reader
1080                .parquet_metadata()
1081                .row_groups()
1082                .first()
1083                .map(|row_group| row_group.num_rows() as usize)
1084                .filter(|size| *size > 0)
1085        });
1086
1087        let mut indexer = self
1088            .indexer_builder
1089            .build(region_file_id, new_index_version, row_group_size)
1090            .await;
1091        // The build type or index options may leave nothing to build. Return before reading
1092        // any row; an empty indexer finishes with no output, which publishes nothing.
1093        if indexer.is_empty() {
1094            return Ok(ControlFlow::Break(self.aborted("No index to build")));
1095        }
1096
1097        if let Some(mut parquet_reader) = parquet_reader.take() {
1098            // TODO(SNC123): optimize index batch
1099            loop {
1100                match parquet_reader.next_record_batch().await {
1101                    Ok(Some(batch)) => {
1102                        indexer.update_flat(&batch).await;
1103                    }
1104                    Ok(None) => break,
1105                    Err(e) => {
1106                        indexer.abort().await;
1107                        return Err(e);
1108                    }
1109                }
1110            }
1111        }
1112        let index_output = indexer.finish().await;
1113
1114        if index_output.file_size == 0 {
1115            // The indexer logs and drops index creation failures.
1116            return Ok(ControlFlow::Break(self.aborted("No index was created")));
1117        }
1118        Ok(ControlFlow::Continue((indexer, index_output)))
1119    }
1120
1121    /// Uploads the index files and commits them to the manifest.
1122    async fn publish(
1123        &self,
1124        region_file_id: RegionFileId,
1125        index_output: IndexOutput,
1126        new_index_version: u64,
1127    ) -> Result<IndexBuildReport> {
1128        // Use the same file_id but with new version for index file.
1129        self.maybe_upload_index_file(
1130            index_output.clone(),
1131            region_file_id.file_id(),
1132            new_index_version,
1133        )
1134        .await?;
1135
1136        self.listener
1137            .on_index_build_before_manifest_commit(region_file_id)
1138            .await;
1139
1140        // The commit may have reached storage even on error, so the uploaded
1141        // index file is left for GC instead of being deleted here.
1142        match self
1143            .update_manifest(index_output, new_index_version)
1144            .await?
1145        {
1146            IndexPublication::Committed {
1147                manifest_version,
1148                file_meta,
1149            } => {
1150                self.listener
1151                    .on_index_build_manifest_committed(region_file_id)
1152                    .await;
1153                self.notify_worker(BackgroundNotify::IndexBuildFinished(IndexBuildFinished {
1154                    manifest_version,
1155                    file_meta,
1156                    result_senders: self.result_senders.clone(),
1157                }))
1158                .await;
1159                Ok(IndexBuildReport::Deferred)
1160            }
1161            IndexPublication::Stale(stale) => Ok(self
1162                .handle_stale_publication(region_file_id, stale, new_index_version)
1163                .await),
1164        }
1165    }
1166
1167    async fn handle_stale_publication(
1168        &self,
1169        region_file_id: RegionFileId,
1170        stale: IndexPublicationStale,
1171        new_index_version: u64,
1172    ) -> IndexBuildReport {
1173        INDEX_PUBLICATION_STALE_TOTAL
1174            .with_label_values(&["manifest_commit"])
1175            .inc();
1176        // If no successor publishes the same version, GC collects the
1177        // remote artifact. Repartition requires GC, while local mode
1178        // accepts this narrow orphan window when GC is disabled.
1179        cleanup_stale_index_caches(
1180            RegionIndexId::new(region_file_id, new_index_version),
1181            &self.access_layer,
1182            self.cache_manager.as_ref(),
1183            self.write_cache.as_ref(),
1184        )
1185        .await;
1186        if stale != IndexPublicationStale::SchemaChanged {
1187            return self
1188                .abort_and_notify(
1189                    region_file_id,
1190                    "Index build source changed before publication",
1191                )
1192                .await;
1193        }
1194
1195        self.listener.on_index_build_abort(region_file_id).await;
1196        // An index-unrelated schema change also invalidates the generation
1197        // fence. Retry to avoid leaving the SST unindexed; per-SST coalescing
1198        // limits this to one active and one pending task, so repeated changes
1199        // cannot storm.
1200        self.notify_worker(BackgroundNotify::IndexBuildRetry(BuildIndexRequest {
1201            region_id: self.region_id,
1202            build_type: IndexBuildType::SchemaChange,
1203            file_metas: vec![self.source.file_meta.clone()],
1204            result_senders: self.result_senders.clone(),
1205        }))
1206        .await;
1207        IndexBuildReport::Deferred
1208    }
1209
1210    async fn maybe_upload_index_file(
1211        &self,
1212        output: IndexOutput,
1213        index_file_id: FileId,
1214        index_version: u64,
1215    ) -> Result<()> {
1216        if let Some(write_cache) = &self.write_cache {
1217            let file_id = self.source.file_meta.file_id;
1218            let region_id = self.source.file_meta.region_id;
1219            let remote_store = self.access_layer.object_store();
1220            let mut upload_tracker = UploadTracker::new(region_id);
1221            let mut err = None;
1222            let puffin_key =
1223                IndexKey::new(region_id, index_file_id, FileType::Puffin(output.version));
1224            let index_id = RegionIndexId::new(RegionFileId::new(region_id, file_id), index_version);
1225            let puffin_path = RegionFilePathFactory::new(
1226                self.access_layer.table_dir().to_string(),
1227                self.access_layer.path_type(),
1228            )
1229            .build_index_file_path_with_version(index_id);
1230            if let Err(e) = write_cache
1231                // Index rebuild is background maintenance work, so its uploads
1232                // are reported as compaction uploads.
1233                .upload(
1234                    puffin_key,
1235                    &puffin_path,
1236                    remote_store,
1237                    UploadOptions {
1238                        op_type: OperationType::Compact,
1239                        write_buffer_size: crate::sst::DEFAULT_WRITE_BUFFER_SIZE,
1240                    },
1241                )
1242                .await
1243            {
1244                err = Some(e);
1245            }
1246            upload_tracker.push_uploaded_file(puffin_path);
1247            if let Some(err) = err {
1248                // Cleans index files on failure.
1249                upload_tracker
1250                    .clean(
1251                        &[SstInfo {
1252                            file_id,
1253                            index_metadata: output,
1254                            ..Default::default()
1255                        }],
1256                        &write_cache.file_cache(),
1257                        remote_store,
1258                    )
1259                    .await;
1260                return Err(err);
1261            }
1262        } else {
1263            debug!("write cache is not available, skip uploading index file");
1264        }
1265        Ok(())
1266    }
1267
1268    async fn update_manifest(
1269        &self,
1270        output: IndexOutput,
1271        new_index_version: u64,
1272    ) -> Result<IndexPublication> {
1273        let mut updated = self.source.file_meta.clone();
1274        updated.available_indexes = output.build_available_indexes();
1275        updated.indexes = output.build_indexes();
1276        updated.index_file_size = output.file_size;
1277        updated.index_version = new_index_version;
1278        let publication = self
1279            .manifest_ctx
1280            .update_manifest_for_index(&self.source, updated)
1281            .await?;
1282        if let IndexPublication::Committed {
1283            manifest_version, ..
1284        } = &publication
1285        {
1286            info!(
1287                "Successfully update manifest version to {}, region: {}, reason: {}",
1288                manifest_version,
1289                self.region_id,
1290                self.reason.as_str()
1291            );
1292        }
1293        Ok(publication)
1294    }
1295}
1296
1297impl PartialEq for IndexBuildTask {
1298    fn eq(&self, other: &Self) -> bool {
1299        self.reason.priority() == other.reason.priority()
1300    }
1301}
1302
1303impl Eq for IndexBuildTask {}
1304
1305impl PartialOrd for IndexBuildTask {
1306    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1307        Some(self.cmp(other))
1308    }
1309}
1310
1311impl Ord for IndexBuildTask {
1312    fn cmp(&self, other: &Self) -> Ordering {
1313        self.reason.priority().cmp(&other.reason.priority())
1314    }
1315}
1316
1317#[derive(Clone)]
1318struct PendingIndexBuild {
1319    task: IndexBuildTask,
1320    version_control: VersionControlRef,
1321}
1322
1323impl PartialEq for PendingIndexBuild {
1324    fn eq(&self, other: &Self) -> bool {
1325        self.task == other.task
1326    }
1327}
1328
1329impl Eq for PendingIndexBuild {}
1330
1331impl PartialOrd for PendingIndexBuild {
1332    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1333        Some(self.cmp(other))
1334    }
1335}
1336
1337impl Ord for PendingIndexBuild {
1338    fn cmp(&self, other: &Self) -> Ordering {
1339        self.task.cmp(&other.task)
1340    }
1341}
1342
1343impl PendingIndexBuild {
1344    /// Returns whether this task should replace another pending task for the
1345    /// same SST.
1346    fn supersedes(&self, other: &Self) -> bool {
1347        match self
1348            .task
1349            .source
1350            .schema_version
1351            .cmp(&other.task.source.schema_version)
1352        {
1353            Ordering::Greater => true,
1354            Ordering::Less => false,
1355            Ordering::Equal => match self
1356                .task
1357                .source
1358                .file_meta
1359                .index_version()
1360                .cmp(&other.task.source.file_meta.index_version())
1361            {
1362                Ordering::Greater => true,
1363                Ordering::Less => false,
1364                Ordering::Equal => self.task.reason.priority() > other.task.reason.priority(),
1365            },
1366        }
1367    }
1368}
1369
1370#[derive(Default)]
1371struct PendingIndexBuilds {
1372    tasks: HashMap<FileId, PendingIndexBuild>,
1373}
1374
1375impl PendingIndexBuilds {
1376    /// Inserts a pending task and returns the task it replaces or that is dropped.
1377    ///
1378    /// The surviving task takes over the result senders of the returned task, so
1379    /// callers waiting on either task wait for the build that actually runs.
1380    fn insert(&mut self, mut pending: PendingIndexBuild) -> Option<IndexBuildTask> {
1381        let file_id = pending.task.source.file_meta.file_id;
1382        match self.tasks.entry(file_id) {
1383            std::collections::hash_map::Entry::Vacant(entry) => {
1384                entry.insert(pending);
1385                None
1386            }
1387            std::collections::hash_map::Entry::Occupied(mut entry) => {
1388                let survivor = entry.get_mut();
1389                if pending.supersedes(survivor) {
1390                    std::mem::swap(survivor, &mut pending);
1391                }
1392                let senders = std::mem::take(&mut pending.task.result_senders);
1393                survivor.task.result_senders.extend(senders);
1394                Some(pending.task)
1395            }
1396        }
1397    }
1398
1399    fn highest_ready(&self, building_files: &HashSet<FileId>) -> Option<PendingIndexBuild> {
1400        self.tasks
1401            .values()
1402            .filter(|pending| !building_files.contains(&pending.task.source.file_meta.file_id))
1403            .max()
1404            .cloned()
1405    }
1406
1407    fn remove(&mut self, file_id: FileId) {
1408        self.tasks.remove(&file_id);
1409    }
1410
1411    fn drain(&mut self) -> impl Iterator<Item = PendingIndexBuild> {
1412        std::mem::take(&mut self.tasks).into_values()
1413    }
1414
1415    #[cfg(test)]
1416    fn len(&self) -> usize {
1417        self.tasks.len()
1418    }
1419
1420    fn is_empty(&self) -> bool {
1421        self.tasks.is_empty()
1422    }
1423}
1424
1425/// Tracks the index build status of a region scheduled by the [IndexBuildScheduler].
1426struct IndexBuildStatus {
1427    building_files: HashSet<FileId>,
1428    /// At most one coalesced pending task is kept for each SST.
1429    pending_tasks: PendingIndexBuilds,
1430    /// Whether the active builds belong to a region incarnation that has
1431    /// stopped accepting index publications.
1432    retiring: bool,
1433}
1434
1435impl IndexBuildStatus {
1436    fn new() -> Self {
1437        IndexBuildStatus {
1438            building_files: HashSet::new(),
1439            pending_tasks: PendingIndexBuilds::default(),
1440            retiring: false,
1441        }
1442    }
1443
1444    async fn fail_pending(&mut self, err: Arc<Error>) {
1445        for pending in self.pending_tasks.drain() {
1446            pending.task.on_failure(err.clone()).await;
1447        }
1448    }
1449
1450    async fn retire(&mut self, err: Arc<Error>) {
1451        self.retiring = !self.building_files.is_empty();
1452        self.fail_pending(err).await;
1453    }
1454}
1455
1456pub struct IndexBuildScheduler {
1457    /// Background job scheduler.
1458    scheduler: SchedulerRef,
1459    /// Tracks regions need to build index.
1460    region_status: HashMap<RegionId, IndexBuildStatus>,
1461    /// Limit of files allowed to build index concurrently on this worker.
1462    files_limit: usize,
1463}
1464
1465/// Manager background index build tasks of a worker.
1466impl IndexBuildScheduler {
1467    pub fn new(scheduler: SchedulerRef, files_limit: usize) -> Self {
1468        IndexBuildScheduler {
1469            scheduler,
1470            region_status: HashMap::new(),
1471            files_limit,
1472        }
1473    }
1474
1475    pub(crate) async fn schedule_build(
1476        &mut self,
1477        version_control: &VersionControlRef,
1478        task: IndexBuildTask,
1479    ) {
1480        let status = self
1481            .region_status
1482            .entry(task.region_id)
1483            .or_insert_with(IndexBuildStatus::new);
1484
1485        let file_id = task.source.file_meta.file_id;
1486        let region_file_id = RegionFileId::new(
1487            task.source.file_meta.region_id,
1488            task.source.file_meta.file_id,
1489        );
1490        // A manual build must not report success while an active build that may
1491        // not create the requested indexes is still running, so it waits as well.
1492        let can_wait_for_active = status.retiring
1493            || matches!(
1494                task.reason,
1495                IndexBuildType::SchemaChange | IndexBuildType::Manual
1496            );
1497        let (rejected, coalesced) =
1498            if status.building_files.contains(&file_id) && !can_wait_for_active {
1499                (Some(task), None)
1500            } else {
1501                let pending = PendingIndexBuild {
1502                    task,
1503                    version_control: version_control.clone(),
1504                };
1505                (None, status.pending_tasks.insert(pending))
1506            };
1507        let should_schedule = !status.retiring;
1508
1509        if let Some(rejected) = rejected {
1510            debug!(
1511                "Rejecting index build because region file {:?} is already being built",
1512                region_file_id
1513            );
1514            rejected
1515                .on_success(IndexBuildOutcome::Aborted(format!(
1516                    "Index is already being built for region file {:?}",
1517                    region_file_id
1518                )))
1519                .await;
1520            rejected.listener.on_index_build_abort(region_file_id).await;
1521        }
1522
1523        if let Some(coalesced) = coalesced {
1524            debug!(
1525                "Coalescing redundant index build task for region file {:?}",
1526                region_file_id
1527            );
1528            coalesced
1529                .on_success(IndexBuildOutcome::Aborted(format!(
1530                    "Index build was coalesced for region file {:?}",
1531                    region_file_id
1532                )))
1533                .await;
1534            coalesced
1535                .listener
1536                .on_index_build_abort(region_file_id)
1537                .await;
1538        }
1539
1540        if should_schedule {
1541            self.schedule_next_build_batch();
1542        }
1543    }
1544
1545    /// Schedule tasks until reaching the files limit or no more tasks.
1546    fn schedule_next_build_batch(&mut self) {
1547        let mut building_count = self
1548            .region_status
1549            .values()
1550            .map(|status| status.building_files.len())
1551            .sum::<usize>();
1552
1553        while building_count < self.files_limit {
1554            let Some(pending) = self.find_next_task() else {
1555                break;
1556            };
1557
1558            let task = pending.task;
1559            let region_id = task.region_id;
1560            let file_id = task.source.file_meta.file_id;
1561            let job = task.clone().into_index_build_job(pending.version_control);
1562            match self.scheduler.schedule(job) {
1563                Ok(()) => {
1564                    if let Some(status) = self.region_status.get_mut(&region_id) {
1565                        status.pending_tasks.remove(file_id);
1566                        status.building_files.insert(file_id);
1567                        building_count += 1;
1568                    } else {
1569                        error!(
1570                            "Region status not found when scheduling index build task, region: {}",
1571                            region_id
1572                        );
1573                    }
1574                }
1575                Err(err) => {
1576                    error!(
1577                        err;
1578                        "Failed to schedule index build job, region: {}, file_id: {}",
1579                        region_id,
1580                        file_id
1581                    );
1582                    if let Some(status) = self.region_status.get_mut(&region_id) {
1583                        status.pending_tasks.remove(file_id);
1584                    }
1585                    common_runtime::spawn_global(async move {
1586                        task.on_failure(Arc::new(err)).await;
1587                    });
1588                }
1589            }
1590        }
1591
1592        self.region_status.retain(|_, status| {
1593            !status.building_files.is_empty() || !status.pending_tasks.is_empty()
1594        });
1595    }
1596
1597    /// Find the ready task with the highest priority.
1598    fn find_next_task(&self) -> Option<PendingIndexBuild> {
1599        self.region_status
1600            .values()
1601            .filter(|status| !status.retiring)
1602            .filter_map(|status| status.pending_tasks.highest_ready(&status.building_files))
1603            .max()
1604    }
1605
1606    pub(crate) fn on_task_stopped(&mut self, region_id: RegionId, file_id: FileId) {
1607        if let Some(status) = self.region_status.get_mut(&region_id) {
1608            if !status.building_files.remove(&file_id) {
1609                debug!(
1610                    "Index build task is not tracked as building, region: {}, file: {}",
1611                    region_id, file_id
1612                );
1613                return;
1614            }
1615            if status.building_files.is_empty() && status.pending_tasks.is_empty() {
1616                // No more tasks for this region, remove it.
1617                self.region_status.remove(&region_id);
1618            } else if status.building_files.is_empty() {
1619                // All builds from the previous region incarnation have stopped.
1620                status.retiring = false;
1621            }
1622        }
1623
1624        self.schedule_next_build_batch();
1625    }
1626
1627    /// Notifies the scheduler that the region is dropped.
1628    pub(crate) async fn on_region_dropped(&mut self, region_id: RegionId) {
1629        self.retire_region(
1630            region_id,
1631            Arc::new(RegionDroppedSnafu { region_id }.build()),
1632        )
1633        .await;
1634    }
1635
1636    /// Notifies the scheduler that the region is closed.
1637    pub(crate) async fn on_region_closed(&mut self, region_id: RegionId) {
1638        self.retire_region(region_id, Arc::new(RegionClosedSnafu { region_id }.build()))
1639            .await;
1640    }
1641
1642    /// Notifies the scheduler that the region is truncated.
1643    pub(crate) async fn on_region_truncated(&mut self, region_id: RegionId) {
1644        self.retire_region(
1645            region_id,
1646            Arc::new(RegionTruncatedSnafu { region_id }.build()),
1647        )
1648        .await;
1649    }
1650
1651    async fn retire_region(&mut self, region_id: RegionId, err: Arc<Error>) {
1652        let Some(status) = self.region_status.get_mut(&region_id) else {
1653            return;
1654        };
1655        status.retire(err).await;
1656        if status.building_files.is_empty() {
1657            self.region_status.remove(&region_id);
1658        }
1659    }
1660}
1661
1662#[cfg(test)]
1663mod tests {
1664    use std::sync::Arc;
1665
1666    use api::v1::SemanticType;
1667    use common_base::readable_size::ReadableSize;
1668    use datafusion_common::HashMap;
1669    use datatypes::data_type::ConcreteDataType;
1670    use datatypes::schema::{
1671        ColumnSchema, FulltextOptions, SkippingIndexOptions, SkippingIndexType,
1672    };
1673    use datatypes::value::Value;
1674    use index::inverted_index::format::reader::InvertedIndexReader;
1675    use object_store::ObjectStore;
1676    use object_store::services::Memory;
1677    use partition::expr::col;
1678    use puffin::puffin_manager::{PuffinManager, PuffinReader};
1679    use puffin_manager::PuffinManagerFactory;
1680    use smallvec::smallvec;
1681    use store_api::metadata::{ColumnMetadata, RegionMetadataBuilder};
1682    use tokio::sync::mpsc;
1683
1684    use super::*;
1685    use crate::access_layer::{FilePathProvider, Metrics, SstWriteRequest, WriteType};
1686    use crate::cache::write_cache::WriteCache;
1687    use crate::config::{FulltextIndexConfig, IndexBuildMode, MitoConfig, Mode};
1688    use crate::manifest::action::{RegionEdit, RegionMetaAction, RegionMetaActionList};
1689    use crate::memtable::time_partition::TimePartitions;
1690    use crate::region::RegionLeaderState;
1691    use crate::region::version::{VersionBuilder, VersionControl};
1692    use crate::schedule::scheduler::{LocalScheduler, Scheduler};
1693    use crate::sst::file::{FileMeta, RegionFileId};
1694    use crate::sst::file_purger::NoopFilePurger;
1695    use crate::sst::location;
1696    use crate::sst::parquet::WriteOptions;
1697    use crate::test_util::memtable_util::EmptyMemtableBuilder;
1698    use crate::test_util::scheduler_util::{SchedulerEnv, VecScheduler};
1699    use crate::test_util::sst_util::{
1700        new_flat_source_from_record_batches, new_record_batch_by_range, sst_region_metadata,
1701    };
1702
1703    struct MetaConfig {
1704        with_inverted: bool,
1705        with_fulltext: bool,
1706        with_skipping_bloom: bool,
1707    }
1708
1709    /// Receives the published index that the worker applies before notifying waiters.
1710    async fn recv_index_build_finished(
1711        rx: &mut mpsc::Receiver<WorkerRequestWithTime>,
1712    ) -> IndexBuildFinished {
1713        match rx.recv().await.unwrap().request {
1714            WorkerRequest::Background {
1715                notify: BackgroundNotify::IndexBuildFinished(finished),
1716                ..
1717            } => finished,
1718            other => panic!("Expect index build finished, got: {other:?}"),
1719        }
1720    }
1721
1722    async fn seed_manifest_file(manifest_ctx: &ManifestContextRef, file_meta: &FileMeta) {
1723        manifest_ctx
1724            .update_manifest(
1725                RegionLeaderState::Writable,
1726                RegionMetaActionList::with_action(RegionMetaAction::Edit(RegionEdit {
1727                    files_to_add: vec![file_meta.clone()],
1728                    files_to_remove: Vec::new(),
1729                    timestamp_ms: None,
1730                    flushed_sequence: None,
1731                    flushed_entry_id: None,
1732                    committed_sequence: None,
1733                    compaction_time_window: None,
1734                })),
1735                false,
1736            )
1737            .await
1738            .unwrap();
1739    }
1740
1741    fn mock_region_metadata(
1742        MetaConfig {
1743            with_inverted,
1744            with_fulltext,
1745            with_skipping_bloom,
1746        }: MetaConfig,
1747    ) -> RegionMetadataRef {
1748        let mut builder = RegionMetadataBuilder::new(RegionId::new(1, 2));
1749        let mut column_schema = ColumnSchema::new("a", ConcreteDataType::int64_datatype(), false);
1750        if with_inverted {
1751            column_schema = column_schema.with_inverted_index(true);
1752        }
1753        builder
1754            .push_column_metadata(ColumnMetadata {
1755                column_schema,
1756                semantic_type: SemanticType::Field,
1757                column_id: 1,
1758            })
1759            .push_column_metadata(ColumnMetadata {
1760                column_schema: ColumnSchema::new("b", ConcreteDataType::float64_datatype(), false),
1761                semantic_type: SemanticType::Field,
1762                column_id: 2,
1763            })
1764            .push_column_metadata(ColumnMetadata {
1765                column_schema: ColumnSchema::new(
1766                    "c",
1767                    ConcreteDataType::timestamp_millisecond_datatype(),
1768                    false,
1769                ),
1770                semantic_type: SemanticType::Timestamp,
1771                column_id: 3,
1772            });
1773
1774        if with_fulltext {
1775            let column_schema =
1776                ColumnSchema::new("text", ConcreteDataType::string_datatype(), true)
1777                    .with_fulltext_options(FulltextOptions {
1778                        enable: true,
1779                        ..Default::default()
1780                    })
1781                    .unwrap();
1782
1783            let column = ColumnMetadata {
1784                column_schema,
1785                semantic_type: SemanticType::Field,
1786                column_id: 4,
1787            };
1788
1789            builder.push_column_metadata(column);
1790        }
1791
1792        if with_skipping_bloom {
1793            let column_schema =
1794                ColumnSchema::new("bloom", ConcreteDataType::string_datatype(), false)
1795                    .with_skipping_options(SkippingIndexOptions::new_unchecked(
1796                        42,
1797                        0.01,
1798                        SkippingIndexType::BloomFilter,
1799                    ))
1800                    .unwrap();
1801
1802            let column = ColumnMetadata {
1803                column_schema,
1804                semantic_type: SemanticType::Field,
1805                column_id: 5,
1806            };
1807
1808            builder.push_column_metadata(column);
1809        }
1810
1811        Arc::new(builder.build().unwrap())
1812    }
1813
1814    fn mock_object_store() -> ObjectStore {
1815        ObjectStore::new(Memory::default()).unwrap()
1816    }
1817
1818    async fn mock_intm_mgr(path: impl AsRef<str>) -> IntermediateManager {
1819        IntermediateManager::init_fs(path).await.unwrap()
1820    }
1821
1822    fn random_region_file_id() -> RegionFileId {
1823        RegionFileId::new(RegionId::new(1, 2), FileId::random())
1824    }
1825
1826    struct NoopPathProvider;
1827
1828    impl FilePathProvider for NoopPathProvider {
1829        fn build_index_file_path(&self, _file_id: RegionFileId) -> String {
1830            unreachable!()
1831        }
1832
1833        fn build_index_file_path_with_version(&self, _index_id: RegionIndexId) -> String {
1834            unreachable!()
1835        }
1836
1837        fn build_sst_file_path(&self, _file_id: RegionFileId) -> String {
1838            unreachable!()
1839        }
1840    }
1841
1842    async fn mock_sst_file(
1843        metadata: RegionMetadataRef,
1844        env: &SchedulerEnv,
1845        build_mode: IndexBuildMode,
1846    ) -> SstInfo {
1847        let source = new_flat_source_from_record_batches(vec![
1848            new_record_batch_by_range(&["a", "d"], 0, 60),
1849            new_record_batch_by_range(&["b", "f"], 0, 40),
1850            new_record_batch_by_range(&["b", "h"], 100, 200),
1851        ]);
1852        let mut index_config = MitoConfig::default().index;
1853        index_config.build_mode = build_mode;
1854        let write_request = SstWriteRequest {
1855            op_type: OperationType::Flush,
1856            metadata: metadata.clone(),
1857            source,
1858            max_sequence: None,
1859            sst_write_format: Default::default(),
1860            cache_manager: Default::default(),
1861            preserve_row_sequence: false,
1862            index_options: IndexOptions::default(),
1863            index_config,
1864            inverted_index_config: Default::default(),
1865            fulltext_index_config: Default::default(),
1866            bloom_filter_index_config: Default::default(),
1867        };
1868        let mut metrics = Metrics::new(WriteType::Flush);
1869        env.access_layer
1870            .write_sst(write_request, &WriteOptions::default(), &mut metrics)
1871            .await
1872            .unwrap()
1873            .remove(0)
1874    }
1875
1876    async fn mock_version_control(
1877        metadata: RegionMetadataRef,
1878        file_purger: FilePurgerRef,
1879        files: HashMap<FileId, FileMeta>,
1880    ) -> VersionControlRef {
1881        let mutable = TimePartitions::new(
1882            metadata.clone(),
1883            Arc::new(EmptyMemtableBuilder::default()),
1884            0,
1885            None,
1886        );
1887        let version_builder = VersionBuilder::new(metadata, mutable)
1888            .add_files(file_purger, files.values().cloned())
1889            .build();
1890        Arc::new(VersionControl::new(version_builder))
1891    }
1892
1893    async fn mock_indexer_builder(
1894        metadata: RegionMetadataRef,
1895        env: &SchedulerEnv,
1896    ) -> Arc<dyn IndexerBuilder + Send + Sync> {
1897        let (dir, factory) = PuffinManagerFactory::new_for_test_async("mock_indexer_builder").await;
1898        let intm_manager = mock_intm_mgr(dir.path().to_string_lossy()).await;
1899        let puffin_manager = factory.build(
1900            env.access_layer.object_store().clone(),
1901            RegionFilePathFactory::new(
1902                env.access_layer.table_dir().to_string(),
1903                env.access_layer.path_type(),
1904            ),
1905        );
1906        Arc::new(IndexerBuilderImpl {
1907            build_type: IndexBuildType::Flush,
1908            metadata,
1909            puffin_manager,
1910            write_cache_enabled: false,
1911            intermediate_manager: intm_manager,
1912            index_options: IndexOptions::default(),
1913            inverted_index_config: InvertedIndexConfig::default(),
1914            fulltext_index_config: FulltextIndexConfig::default(),
1915            bloom_filter_index_config: BloomFilterConfig::default(),
1916        })
1917    }
1918
1919    #[tokio::test]
1920    async fn test_build_indexer_basic() {
1921        let (dir, factory) =
1922            PuffinManagerFactory::new_for_test_async("test_build_indexer_basic_").await;
1923        let intm_manager = mock_intm_mgr(dir.path().to_string_lossy()).await;
1924
1925        let metadata = mock_region_metadata(MetaConfig {
1926            with_inverted: true,
1927            with_fulltext: true,
1928            with_skipping_bloom: true,
1929        });
1930        let indexer = IndexerBuilderImpl {
1931            build_type: IndexBuildType::Flush,
1932            metadata,
1933            puffin_manager: factory.build(mock_object_store(), NoopPathProvider),
1934            write_cache_enabled: false,
1935            intermediate_manager: intm_manager,
1936            index_options: IndexOptions::default(),
1937            inverted_index_config: InvertedIndexConfig::default(),
1938            fulltext_index_config: FulltextIndexConfig::default(),
1939            bloom_filter_index_config: BloomFilterConfig::default(),
1940        }
1941        .build(random_region_file_id(), 0, Some(1024))
1942        .await;
1943
1944        assert!(indexer.inverted_indexer.is_some());
1945        assert!(indexer.fulltext_indexer.is_some());
1946        assert!(indexer.bloom_filter_indexer.is_some());
1947    }
1948
1949    #[tokio::test]
1950    async fn test_build_indexer_disable_create() {
1951        let (dir, factory) =
1952            PuffinManagerFactory::new_for_test_async("test_build_indexer_disable_create_").await;
1953        let intm_manager = mock_intm_mgr(dir.path().to_string_lossy()).await;
1954
1955        let metadata = mock_region_metadata(MetaConfig {
1956            with_inverted: true,
1957            with_fulltext: true,
1958            with_skipping_bloom: true,
1959        });
1960        let indexer = IndexerBuilderImpl {
1961            build_type: IndexBuildType::Flush,
1962            metadata: metadata.clone(),
1963            puffin_manager: factory.build(mock_object_store(), NoopPathProvider),
1964            write_cache_enabled: false,
1965            intermediate_manager: intm_manager.clone(),
1966            index_options: IndexOptions::default(),
1967            inverted_index_config: InvertedIndexConfig {
1968                create_on_flush: Mode::Disable,
1969                ..Default::default()
1970            },
1971            fulltext_index_config: FulltextIndexConfig::default(),
1972            bloom_filter_index_config: BloomFilterConfig::default(),
1973        }
1974        .build(random_region_file_id(), 0, Some(1024))
1975        .await;
1976
1977        assert!(indexer.inverted_indexer.is_none());
1978        assert!(indexer.fulltext_indexer.is_some());
1979        assert!(indexer.bloom_filter_indexer.is_some());
1980
1981        let indexer = IndexerBuilderImpl {
1982            build_type: IndexBuildType::Compact,
1983            metadata: metadata.clone(),
1984            puffin_manager: factory.build(mock_object_store(), NoopPathProvider),
1985            write_cache_enabled: false,
1986            intermediate_manager: intm_manager.clone(),
1987            index_options: IndexOptions::default(),
1988            inverted_index_config: InvertedIndexConfig::default(),
1989            fulltext_index_config: FulltextIndexConfig {
1990                create_on_compaction: Mode::Disable,
1991                ..Default::default()
1992            },
1993            bloom_filter_index_config: BloomFilterConfig::default(),
1994        }
1995        .build(random_region_file_id(), 0, Some(1024))
1996        .await;
1997
1998        assert!(indexer.inverted_indexer.is_some());
1999        assert!(indexer.fulltext_indexer.is_none());
2000        assert!(indexer.bloom_filter_indexer.is_some());
2001
2002        let indexer = IndexerBuilderImpl {
2003            build_type: IndexBuildType::Compact,
2004            metadata,
2005            puffin_manager: factory.build(mock_object_store(), NoopPathProvider),
2006            write_cache_enabled: false,
2007            intermediate_manager: intm_manager,
2008            index_options: IndexOptions::default(),
2009            inverted_index_config: InvertedIndexConfig::default(),
2010            fulltext_index_config: FulltextIndexConfig::default(),
2011            bloom_filter_index_config: BloomFilterConfig {
2012                create_on_compaction: Mode::Disable,
2013                ..Default::default()
2014            },
2015        }
2016        .build(random_region_file_id(), 0, Some(1024))
2017        .await;
2018
2019        assert!(indexer.inverted_indexer.is_some());
2020        assert!(indexer.fulltext_indexer.is_some());
2021        assert!(indexer.bloom_filter_indexer.is_none());
2022    }
2023
2024    #[tokio::test]
2025    async fn test_build_indexer_no_required() {
2026        let (dir, factory) =
2027            PuffinManagerFactory::new_for_test_async("test_build_indexer_no_required_").await;
2028        let intm_manager = mock_intm_mgr(dir.path().to_string_lossy()).await;
2029
2030        let metadata = mock_region_metadata(MetaConfig {
2031            with_inverted: false,
2032            with_fulltext: true,
2033            with_skipping_bloom: true,
2034        });
2035        let indexer = IndexerBuilderImpl {
2036            build_type: IndexBuildType::Flush,
2037            metadata: metadata.clone(),
2038            puffin_manager: factory.build(mock_object_store(), NoopPathProvider),
2039            write_cache_enabled: false,
2040            intermediate_manager: intm_manager.clone(),
2041            index_options: IndexOptions::default(),
2042            inverted_index_config: InvertedIndexConfig::default(),
2043            fulltext_index_config: FulltextIndexConfig::default(),
2044            bloom_filter_index_config: BloomFilterConfig::default(),
2045        }
2046        .build(random_region_file_id(), 0, Some(1024))
2047        .await;
2048
2049        assert!(indexer.inverted_indexer.is_none());
2050        assert!(indexer.fulltext_indexer.is_some());
2051        assert!(indexer.bloom_filter_indexer.is_some());
2052
2053        let metadata = mock_region_metadata(MetaConfig {
2054            with_inverted: true,
2055            with_fulltext: false,
2056            with_skipping_bloom: true,
2057        });
2058        let indexer = IndexerBuilderImpl {
2059            build_type: IndexBuildType::Flush,
2060            metadata: metadata.clone(),
2061            puffin_manager: factory.build(mock_object_store(), NoopPathProvider),
2062            write_cache_enabled: false,
2063            intermediate_manager: intm_manager.clone(),
2064            index_options: IndexOptions::default(),
2065            inverted_index_config: InvertedIndexConfig::default(),
2066            fulltext_index_config: FulltextIndexConfig::default(),
2067            bloom_filter_index_config: BloomFilterConfig::default(),
2068        }
2069        .build(random_region_file_id(), 0, Some(1024))
2070        .await;
2071
2072        assert!(indexer.inverted_indexer.is_some());
2073        assert!(indexer.fulltext_indexer.is_none());
2074        assert!(indexer.bloom_filter_indexer.is_some());
2075
2076        let metadata = mock_region_metadata(MetaConfig {
2077            with_inverted: true,
2078            with_fulltext: true,
2079            with_skipping_bloom: false,
2080        });
2081        let indexer = IndexerBuilderImpl {
2082            build_type: IndexBuildType::Flush,
2083            metadata: metadata.clone(),
2084            puffin_manager: factory.build(mock_object_store(), NoopPathProvider),
2085            write_cache_enabled: false,
2086            intermediate_manager: intm_manager,
2087            index_options: IndexOptions::default(),
2088            inverted_index_config: InvertedIndexConfig::default(),
2089            fulltext_index_config: FulltextIndexConfig::default(),
2090            bloom_filter_index_config: BloomFilterConfig::default(),
2091        }
2092        .build(random_region_file_id(), 0, Some(1024))
2093        .await;
2094
2095        assert!(indexer.inverted_indexer.is_some());
2096        assert!(indexer.fulltext_indexer.is_some());
2097        assert!(indexer.bloom_filter_indexer.is_none());
2098    }
2099
2100    #[tokio::test]
2101    async fn test_build_indexer_zero_row_group_hint() {
2102        let (dir, factory) =
2103            PuffinManagerFactory::new_for_test_async("test_build_indexer_zero_row_group_").await;
2104        let intm_manager = mock_intm_mgr(dir.path().to_string_lossy()).await;
2105
2106        let metadata = mock_region_metadata(MetaConfig {
2107            with_inverted: true,
2108            with_fulltext: true,
2109            with_skipping_bloom: true,
2110        });
2111        let indexer = IndexerBuilderImpl {
2112            build_type: IndexBuildType::Flush,
2113            metadata,
2114            puffin_manager: factory.build(mock_object_store(), NoopPathProvider),
2115            write_cache_enabled: false,
2116            intermediate_manager: intm_manager,
2117            index_options: IndexOptions::default(),
2118            inverted_index_config: InvertedIndexConfig::default(),
2119            fulltext_index_config: FulltextIndexConfig::default(),
2120            bloom_filter_index_config: BloomFilterConfig::default(),
2121        }
2122        .build(random_region_file_id(), 0, Some(0))
2123        .await;
2124
2125        assert!(indexer.inverted_indexer.is_some());
2126    }
2127
2128    #[tokio::test]
2129    async fn test_index_build_task_sst_not_exist() {
2130        let env = SchedulerEnv::new().await;
2131        let (tx, _rx) = mpsc::channel(4);
2132        let (result_tx, mut result_rx) = mpsc::channel::<Result<IndexBuildOutcome>>(4);
2133        let mut scheduler = env.mock_index_build_scheduler(4);
2134        let metadata = Arc::new(sst_region_metadata());
2135        let manifest_ctx = env.mock_manifest_context(metadata.clone()).await;
2136        let file_purger = Arc::new(NoopFilePurger {});
2137        let files = HashMap::new();
2138        let version_control =
2139            mock_version_control(metadata.clone(), file_purger.clone(), files).await;
2140        let region_id = metadata.region_id;
2141        let indexer_builder = mock_indexer_builder(metadata, &env).await;
2142
2143        let file_meta = FileMeta {
2144            region_id,
2145            file_id: FileId::random(),
2146            file_size: 100,
2147            ..Default::default()
2148        };
2149
2150        let file = FileHandle::new(file_meta.clone(), file_purger.clone());
2151
2152        // Create mock task.
2153        let task = IndexBuildTask {
2154            region_id,
2155            file,
2156            target_region_metadata: version_control.current().version.metadata.clone(),
2157            source: IndexBuildSource::new(
2158                file_meta,
2159                version_control.current().version.metadata.schema_version,
2160            ),
2161            reason: IndexBuildType::Flush,
2162            access_layer: env.access_layer.clone(),
2163            listener: WorkerListener::default(),
2164            manifest_ctx,
2165            write_cache: None,
2166            cache_manager: None,
2167            file_purger,
2168            indexer_builder,
2169            request_sender: tx,
2170            index_options: IndexOptions::default(),
2171            result_senders: vec![result_tx],
2172        };
2173
2174        // Schedule the build task and check result.
2175        scheduler.schedule_build(&version_control, task).await;
2176        match result_rx.recv().await.unwrap() {
2177            Ok(outcome) => {
2178                if outcome == IndexBuildOutcome::Finished {
2179                    panic!("Expect aborted result due to missing SST file")
2180                }
2181            }
2182            _ => panic!("Expect aborted result due to missing SST file"),
2183        }
2184    }
2185
2186    #[tokio::test]
2187    async fn test_index_build_task_foreign_file_uses_target_metadata() {
2188        let env = SchedulerEnv::new().await;
2189        let mut scheduler = env.mock_index_build_scheduler(4);
2190        let source_metadata = Arc::new(sst_region_metadata());
2191        let mut target_metadata = (*source_metadata).clone();
2192        target_metadata.region_id = RegionId::new(1, 3);
2193        let mut target_builder = RegionMetadataBuilder::new(target_metadata.region_id);
2194        for mut column_metadata in target_metadata.column_metadatas.clone() {
2195            if column_metadata.column_id == 2 {
2196                column_metadata.column_schema =
2197                    column_metadata.column_schema.with_inverted_index(true);
2198            }
2199            target_builder.push_column_metadata(column_metadata);
2200        }
2201        let partition_expr = col("field_0")
2202            .gt_eq(Value::UInt64(100))
2203            .and(col("field_0").lt(Value::UInt64(200)));
2204        target_builder
2205            .primary_key(target_metadata.primary_key.clone())
2206            .partition_expr_json(Some(partition_expr.as_json_str().unwrap()))
2207            .bump_version();
2208        let target_metadata = Arc::new(target_builder.build().unwrap());
2209        let manifest_ctx = env.mock_manifest_context(target_metadata.clone()).await;
2210        let region_id = target_metadata.region_id;
2211        let file_purger = Arc::new(NoopFilePurger {});
2212        let sst_info = mock_sst_file(source_metadata.clone(), &env, IndexBuildMode::Async).await;
2213        let file_meta = FileMeta {
2214            region_id: source_metadata.region_id,
2215            file_id: sst_info.file_id,
2216            file_size: sst_info.file_size,
2217            max_row_group_uncompressed_size: sst_info.max_row_group_uncompressed_size,
2218            available_indexes: smallvec![IndexType::InvertedIndex],
2219            // Old manifests may publish an index without recording its size.
2220            index_file_size: 0,
2221            index_version: 0,
2222            num_rows: sst_info.num_rows as u64,
2223            num_row_groups: sst_info.num_row_groups,
2224            ..Default::default()
2225        };
2226        seed_manifest_file(&manifest_ctx, &file_meta).await;
2227        let files = HashMap::from([(file_meta.file_id, file_meta.clone())]);
2228        let version_control =
2229            mock_version_control(target_metadata.clone(), file_purger.clone(), files).await;
2230        let indexer_builder = mock_indexer_builder(target_metadata.clone(), &env).await;
2231
2232        let file = FileHandle::new(file_meta.clone(), file_purger.clone());
2233
2234        // Create mock task.
2235        let (tx, mut rx) = mpsc::channel(4);
2236        let (result_tx, _result_rx) = mpsc::channel::<Result<IndexBuildOutcome>>(4);
2237        let task = IndexBuildTask {
2238            region_id,
2239            file,
2240            target_region_metadata: version_control.current().version.metadata.clone(),
2241            source: IndexBuildSource::new(
2242                file_meta.clone(),
2243                version_control.current().version.metadata.schema_version,
2244            ),
2245            reason: IndexBuildType::Flush,
2246            access_layer: env.access_layer.clone(),
2247            listener: WorkerListener::default(),
2248            manifest_ctx,
2249            write_cache: None,
2250            cache_manager: None,
2251            file_purger,
2252            indexer_builder,
2253            request_sender: tx,
2254            index_options: IndexOptions::default(),
2255            result_senders: vec![result_tx],
2256        };
2257
2258        scheduler.schedule_build(&version_control, task).await;
2259
2260        // A notification should be sent to the worker to update the manifest.
2261        let worker_req = rx.recv().await.unwrap().request;
2262        match worker_req {
2263            WorkerRequest::Background {
2264                region_id: req_region_id,
2265                notify: BackgroundNotify::IndexBuildFinished(finished),
2266            } => {
2267                assert_eq!(req_region_id, region_id);
2268                let updated_meta = &finished.file_meta;
2269
2270                // The mock indexer builder creates all index types.
2271                assert!(!updated_meta.available_indexes.is_empty());
2272                assert!(updated_meta.index_file_size > 0);
2273                assert_eq!(updated_meta.file_id, file_meta.file_id);
2274                assert_eq!(updated_meta.index_version, 1);
2275                let field_0_index = updated_meta
2276                    .indexes
2277                    .iter()
2278                    .find(|index| index.column_id == 2)
2279                    .expect("field_0 should have an inverted index");
2280                assert_eq!(
2281                    field_0_index.created_indexes.as_slice(),
2282                    [IndexType::InvertedIndex]
2283                );
2284            }
2285            _ => panic!("Unexpected worker request: {:?}", worker_req),
2286        }
2287
2288        let puffin_reader = env
2289            .access_layer
2290            .build_puffin_manager()
2291            .reader(&RegionIndexId::new(
2292                RegionFileId::new(source_metadata.region_id, file_meta.file_id),
2293                1,
2294            ))
2295            .await
2296            .unwrap();
2297        let blob = puffin_reader
2298            .blob(inverted_index::INDEX_BLOB_TYPE)
2299            .await
2300            .unwrap();
2301        let blob_reader = blob.reader().await.unwrap();
2302        let index_metadata =
2303            index::inverted_index::format::reader::InvertedIndexBlobReader::new(blob_reader)
2304                .metadata(None)
2305                .await
2306                .unwrap();
2307        assert!(index_metadata.metas.contains_key("2"));
2308        assert_eq!(index_metadata.total_row_count, 100);
2309    }
2310
2311    async fn schedule_index_build_task_with_mode(build_mode: IndexBuildMode) {
2312        let env = SchedulerEnv::new().await;
2313        let mut scheduler = env.mock_index_build_scheduler(4);
2314        let metadata = Arc::new(sst_region_metadata());
2315        let manifest_ctx = env.mock_manifest_context(metadata.clone()).await;
2316        let file_purger = Arc::new(NoopFilePurger {});
2317        let region_id = metadata.region_id;
2318        let sst_info = mock_sst_file(metadata.clone(), &env, build_mode.clone()).await;
2319        let file_meta = FileMeta {
2320            region_id,
2321            file_id: sst_info.file_id,
2322            file_size: sst_info.file_size,
2323            max_row_group_uncompressed_size: sst_info.max_row_group_uncompressed_size,
2324            index_file_size: sst_info.index_metadata.file_size,
2325            num_rows: sst_info.num_rows as u64,
2326            num_row_groups: sst_info.num_row_groups,
2327            ..Default::default()
2328        };
2329        seed_manifest_file(&manifest_ctx, &file_meta).await;
2330        let files = HashMap::from([(file_meta.file_id, file_meta.clone())]);
2331        let version_control =
2332            mock_version_control(metadata.clone(), file_purger.clone(), files).await;
2333        let indexer_builder = mock_indexer_builder(metadata.clone(), &env).await;
2334
2335        let file = FileHandle::new(file_meta.clone(), file_purger.clone());
2336
2337        // Create mock task.
2338        let (tx, mut rx) = mpsc::channel(4);
2339        let (result_tx, _result_rx) = mpsc::channel::<Result<IndexBuildOutcome>>(4);
2340        let task = IndexBuildTask {
2341            region_id,
2342            file,
2343            target_region_metadata: version_control.current().version.metadata.clone(),
2344            source: IndexBuildSource::new(
2345                file_meta.clone(),
2346                version_control.current().version.metadata.schema_version,
2347            ),
2348            reason: IndexBuildType::Flush,
2349            access_layer: env.access_layer.clone(),
2350            listener: WorkerListener::default(),
2351            manifest_ctx,
2352            write_cache: None,
2353            cache_manager: None,
2354            file_purger,
2355            indexer_builder,
2356            request_sender: tx,
2357            index_options: IndexOptions::default(),
2358            result_senders: vec![result_tx],
2359        };
2360
2361        scheduler.schedule_build(&version_control, task).await;
2362
2363        let puffin_path = location::index_file_path(
2364            env.access_layer.table_dir(),
2365            RegionIndexId::new(RegionFileId::new(region_id, file_meta.file_id), 0),
2366            env.access_layer.path_type(),
2367        );
2368
2369        if build_mode == IndexBuildMode::Async {
2370            // The index file should not exist before the task finishes.
2371            assert!(
2372                !env.access_layer
2373                    .object_store()
2374                    .exists(&puffin_path)
2375                    .await
2376                    .unwrap()
2377            );
2378        } else {
2379            // The index file should exist before the task finishes.
2380            assert!(
2381                env.access_layer
2382                    .object_store()
2383                    .exists(&puffin_path)
2384                    .await
2385                    .unwrap()
2386            );
2387        }
2388
2389        // The worker is notified to apply the published index.
2390        recv_index_build_finished(&mut rx).await;
2391
2392        // The index file should exist after the task finishes.
2393        assert!(
2394            env.access_layer
2395                .object_store()
2396                .exists(&puffin_path)
2397                .await
2398                .unwrap()
2399        );
2400    }
2401
2402    #[tokio::test]
2403    async fn test_index_build_task_build_mode() {
2404        schedule_index_build_task_with_mode(IndexBuildMode::Async).await;
2405        schedule_index_build_task_with_mode(IndexBuildMode::Sync).await;
2406    }
2407
2408    #[tokio::test]
2409    async fn test_index_build_task_no_index() {
2410        let env = SchedulerEnv::new().await;
2411        let mut scheduler = env.mock_index_build_scheduler(4);
2412        let mut metadata = sst_region_metadata();
2413        // Unset indexes in metadata to simulate no index scenario.
2414        metadata.column_metadatas.iter_mut().for_each(|col| {
2415            col.column_schema.set_inverted_index(false);
2416            let _ = col.column_schema.unset_skipping_options();
2417        });
2418        let region_id = metadata.region_id;
2419        let metadata = Arc::new(metadata);
2420        let manifest_ctx = env.mock_manifest_context(metadata.clone()).await;
2421        let file_purger = Arc::new(NoopFilePurger {});
2422        let sst_info = mock_sst_file(metadata.clone(), &env, IndexBuildMode::Async).await;
2423        let file_meta = FileMeta {
2424            region_id,
2425            file_id: sst_info.file_id,
2426            file_size: sst_info.file_size,
2427            max_row_group_uncompressed_size: sst_info.max_row_group_uncompressed_size,
2428            index_file_size: sst_info.index_metadata.file_size,
2429            num_rows: sst_info.num_rows as u64,
2430            num_row_groups: sst_info.num_row_groups,
2431            ..Default::default()
2432        };
2433        seed_manifest_file(&manifest_ctx, &file_meta).await;
2434        let files = HashMap::from([(file_meta.file_id, file_meta.clone())]);
2435        let version_control =
2436            mock_version_control(metadata.clone(), file_purger.clone(), files).await;
2437        let indexer_builder = mock_indexer_builder(metadata.clone(), &env).await;
2438
2439        let file = FileHandle::new(file_meta.clone(), file_purger.clone());
2440
2441        // Create mock task.
2442        let (tx, mut rx) = mpsc::channel(4);
2443        let (result_tx, mut result_rx) = mpsc::channel::<Result<IndexBuildOutcome>>(4);
2444        let task = IndexBuildTask {
2445            region_id,
2446            file,
2447            target_region_metadata: version_control.current().version.metadata.clone(),
2448            source: IndexBuildSource::new(
2449                file_meta.clone(),
2450                version_control.current().version.metadata.schema_version,
2451            ),
2452            reason: IndexBuildType::Flush,
2453            access_layer: env.access_layer.clone(),
2454            listener: WorkerListener::default(),
2455            manifest_ctx,
2456            write_cache: None,
2457            cache_manager: None,
2458            file_purger,
2459            indexer_builder,
2460            request_sender: tx,
2461            index_options: IndexOptions::default(),
2462            result_senders: vec![result_tx],
2463        };
2464
2465        scheduler.schedule_build(&version_control, task).await;
2466
2467        // No index needs to be built, so the SST is not read.
2468        assert!(matches!(
2469            result_rx.recv().await.unwrap(),
2470            Ok(IndexBuildOutcome::Aborted(reason)) if reason.contains("No index to build")
2471        ));
2472
2473        // Only the stop notification reaches the worker.
2474        assert!(matches!(
2475            rx.recv().await.unwrap().request,
2476            WorkerRequest::Background {
2477                notify: BackgroundNotify::IndexBuildStopped(_),
2478                ..
2479            }
2480        ));
2481    }
2482
2483    #[tokio::test]
2484    async fn test_index_build_task_with_write_cache() {
2485        let env = SchedulerEnv::new().await;
2486        let mut scheduler = env.mock_index_build_scheduler(4);
2487        let metadata = Arc::new(sst_region_metadata());
2488        let manifest_ctx = env.mock_manifest_context(metadata.clone()).await;
2489        let file_purger = Arc::new(NoopFilePurger {});
2490        let region_id = metadata.region_id;
2491
2492        let (dir, factory) = PuffinManagerFactory::new_for_test_async("test_write_cache").await;
2493        let intm_manager = mock_intm_mgr(dir.path().to_string_lossy()).await;
2494
2495        // Create mock write cache
2496        let write_cache = Arc::new(
2497            WriteCache::new_fs(
2498                dir.path().to_str().unwrap(),
2499                ReadableSize::mb(10),
2500                None,
2501                None,
2502                true, // enable_background_worker
2503                factory,
2504                intm_manager,
2505                ReadableSize::mb(10),
2506            )
2507            .await
2508            .unwrap(),
2509        );
2510        // Indexer builder built from write cache.
2511        let indexer_builder = Arc::new(IndexerBuilderImpl {
2512            build_type: IndexBuildType::Flush,
2513            metadata: metadata.clone(),
2514            puffin_manager: write_cache.build_puffin_manager().clone(),
2515            write_cache_enabled: true,
2516            intermediate_manager: write_cache.intermediate_manager().clone(),
2517            index_options: IndexOptions::default(),
2518            inverted_index_config: InvertedIndexConfig::default(),
2519            fulltext_index_config: FulltextIndexConfig::default(),
2520            bloom_filter_index_config: BloomFilterConfig::default(),
2521        });
2522
2523        let sst_info = mock_sst_file(metadata.clone(), &env, IndexBuildMode::Async).await;
2524        let file_meta = FileMeta {
2525            region_id,
2526            file_id: sst_info.file_id,
2527            file_size: sst_info.file_size,
2528            index_file_size: sst_info.index_metadata.file_size,
2529            num_rows: sst_info.num_rows as u64,
2530            num_row_groups: sst_info.num_row_groups,
2531            ..Default::default()
2532        };
2533        seed_manifest_file(&manifest_ctx, &file_meta).await;
2534        let files = HashMap::from([(file_meta.file_id, file_meta.clone())]);
2535        let version_control =
2536            mock_version_control(metadata.clone(), file_purger.clone(), files).await;
2537
2538        let file = FileHandle::new(file_meta.clone(), file_purger.clone());
2539
2540        // Create mock task.
2541        let (tx, mut rx) = mpsc::channel(4);
2542        let (result_tx, _result_rx) = mpsc::channel::<Result<IndexBuildOutcome>>(4);
2543        let task = IndexBuildTask {
2544            region_id,
2545            file,
2546            target_region_metadata: version_control.current().version.metadata.clone(),
2547            source: IndexBuildSource::new(
2548                file_meta.clone(),
2549                version_control.current().version.metadata.schema_version,
2550            ),
2551            reason: IndexBuildType::Flush,
2552            access_layer: env.access_layer.clone(),
2553            listener: WorkerListener::default(),
2554            manifest_ctx,
2555            write_cache: Some(write_cache.clone()),
2556            cache_manager: None,
2557            file_purger,
2558            indexer_builder,
2559            request_sender: tx,
2560            index_options: IndexOptions::default(),
2561            result_senders: vec![result_tx],
2562        };
2563
2564        scheduler.schedule_build(&version_control, task).await;
2565
2566        recv_index_build_finished(&mut rx).await;
2567
2568        // The write cache should contain the uploaded index file.
2569        let index_key = IndexKey::new(
2570            region_id,
2571            file_meta.file_id,
2572            FileType::Puffin(sst_info.index_metadata.version),
2573        );
2574        assert!(write_cache.file_cache().contains_key(&index_key));
2575    }
2576
2577    async fn create_mock_task_for_schedule(
2578        env: &SchedulerEnv,
2579        file_id: FileId,
2580        region_id: RegionId,
2581        reason: IndexBuildType,
2582    ) -> IndexBuildTask {
2583        create_mock_task_for_schedule_with_result(env, file_id, region_id, reason)
2584            .await
2585            .0
2586    }
2587
2588    /// Like [`create_mock_task_for_schedule`] but also returns the result receiver
2589    /// so tests can verify pending task cancellation errors.
2590    async fn create_mock_task_for_schedule_with_result(
2591        env: &SchedulerEnv,
2592        file_id: FileId,
2593        region_id: RegionId,
2594        reason: IndexBuildType,
2595    ) -> (IndexBuildTask, mpsc::Receiver<Result<IndexBuildOutcome>>) {
2596        let metadata = Arc::new(sst_region_metadata());
2597        let schema_version = metadata.schema_version;
2598        let manifest_ctx = env.mock_manifest_context(metadata.clone()).await;
2599        let file_purger = Arc::new(NoopFilePurger {});
2600        let indexer_builder = mock_indexer_builder(metadata.clone(), env).await;
2601        let (tx, _rx) = mpsc::channel(4);
2602        let (result_tx, result_rx) = mpsc::channel::<Result<IndexBuildOutcome>>(4);
2603
2604        let file_meta = FileMeta {
2605            region_id,
2606            file_id,
2607            file_size: 100,
2608            ..Default::default()
2609        };
2610
2611        let file = FileHandle::new(file_meta.clone(), file_purger.clone());
2612
2613        let task = IndexBuildTask {
2614            region_id,
2615            file,
2616            target_region_metadata: metadata,
2617            source: IndexBuildSource::new(file_meta, schema_version),
2618            reason,
2619            access_layer: env.access_layer.clone(),
2620            listener: WorkerListener::default(),
2621            manifest_ctx,
2622            write_cache: None,
2623            cache_manager: None,
2624            file_purger,
2625            indexer_builder,
2626            request_sender: tx,
2627            index_options: IndexOptions::default(),
2628            result_senders: vec![result_tx],
2629        };
2630        (task, result_rx)
2631    }
2632
2633    #[tokio::test]
2634    async fn test_scheduler_coalesces_latest_schema_generation_per_sst() {
2635        let job_scheduler = Arc::new(VecScheduler::default());
2636        let env = SchedulerEnv::new().await.scheduler(job_scheduler.clone());
2637        let mut scheduler = env.mock_index_build_scheduler(2);
2638        let metadata = Arc::new(sst_region_metadata());
2639        let region_id = metadata.region_id;
2640        let file_id = FileId::random();
2641        let file_purger = Arc::new(NoopFilePurger {});
2642        let files = HashMap::from([(
2643            file_id,
2644            FileMeta {
2645                region_id,
2646                file_id,
2647                file_size: 100,
2648                ..Default::default()
2649            },
2650        )]);
2651        let version_control = mock_version_control(metadata, file_purger, files).await;
2652
2653        let (active, _active_rx) = create_mock_task_for_schedule_with_result(
2654            &env,
2655            file_id,
2656            region_id,
2657            IndexBuildType::Manual,
2658        )
2659        .await;
2660        scheduler.schedule_build(&version_control, active).await;
2661        assert_eq!(job_scheduler.num_jobs(), 1);
2662
2663        let (mut older, mut older_rx) = create_mock_task_for_schedule_with_result(
2664            &env,
2665            file_id,
2666            region_id,
2667            IndexBuildType::SchemaChange,
2668        )
2669        .await;
2670        older.source.schema_version = 1;
2671        scheduler.schedule_build(&version_control, older).await;
2672
2673        let (mut latest, mut latest_rx) = create_mock_task_for_schedule_with_result(
2674            &env,
2675            file_id,
2676            region_id,
2677            IndexBuildType::SchemaChange,
2678        )
2679        .await;
2680        latest.source.schema_version = 2;
2681        scheduler.schedule_build(&version_control, latest).await;
2682
2683        // The replaced task's caller waits for the surviving build instead of
2684        // being told the replaced build has completed.
2685        for rx in [&mut older_rx, &mut latest_rx] {
2686            assert!(matches!(
2687                rx.try_recv(),
2688                Err(mpsc::error::TryRecvError::Empty)
2689            ));
2690        }
2691
2692        let status = &scheduler.region_status[&region_id];
2693        assert_eq!(status.building_files.len(), 1);
2694        assert_eq!(status.pending_tasks.len(), 1);
2695        let survivor = &status.pending_tasks.tasks[&file_id].task;
2696        assert_eq!(survivor.source.schema_version, 2);
2697        assert_eq!(survivor.result_senders.len(), 2);
2698        assert_eq!(job_scheduler.num_jobs(), 1);
2699
2700        scheduler.on_task_stopped(region_id, file_id);
2701        let status = &scheduler.region_status[&region_id];
2702        assert_eq!(status.building_files.len(), 1);
2703        assert!(status.pending_tasks.is_empty());
2704        assert_eq!(job_scheduler.num_jobs(), 2);
2705    }
2706
2707    #[tokio::test]
2708    async fn test_scheduler_completes_sender_when_job_is_rejected() {
2709        let job_scheduler = Arc::new(LocalScheduler::new(1));
2710        job_scheduler.stop(false).await.unwrap();
2711        let env = SchedulerEnv::new().await.scheduler(job_scheduler);
2712        let mut scheduler = env.mock_index_build_scheduler(1);
2713        let metadata = Arc::new(sst_region_metadata());
2714        let region_id = metadata.region_id;
2715        let file_id = FileId::random();
2716        let file_purger = Arc::new(NoopFilePurger {});
2717        let files = HashMap::from([(
2718            file_id,
2719            FileMeta {
2720                region_id,
2721                file_id,
2722                file_size: 100,
2723                ..Default::default()
2724            },
2725        )]);
2726        let version_control = mock_version_control(metadata, file_purger, files).await;
2727        let (task, mut result_rx) = create_mock_task_for_schedule_with_result(
2728            &env,
2729            file_id,
2730            region_id,
2731            IndexBuildType::Flush,
2732        )
2733        .await;
2734
2735        scheduler.schedule_build(&version_control, task).await;
2736
2737        let result = tokio::time::timeout(std::time::Duration::from_secs(5), result_rx.recv())
2738            .await
2739            .expect("scheduler rejection did not complete the result sender")
2740            .expect("result channel closed without a result");
2741        assert!(result.is_err());
2742        assert!(!scheduler.region_status.contains_key(&region_id));
2743    }
2744
2745    #[tokio::test]
2746    async fn test_scheduler_comprehensive() {
2747        let env = SchedulerEnv::new().await;
2748        let mut scheduler = env.mock_index_build_scheduler(2);
2749        let metadata = Arc::new(sst_region_metadata());
2750        let region_id = metadata.region_id;
2751        let file_purger = Arc::new(NoopFilePurger {});
2752
2753        // Prepare multiple files for testing
2754        let file_id1 = FileId::random();
2755        let file_id2 = FileId::random();
2756        let file_id3 = FileId::random();
2757        let file_id4 = FileId::random();
2758        let file_id5 = FileId::random();
2759
2760        let mut files = HashMap::new();
2761        for file_id in [file_id1, file_id2, file_id3, file_id4, file_id5] {
2762            files.insert(
2763                file_id,
2764                FileMeta {
2765                    region_id,
2766                    file_id,
2767                    file_size: 100,
2768                    ..Default::default()
2769                },
2770            );
2771        }
2772
2773        let version_control = mock_version_control(metadata, file_purger, files).await;
2774
2775        // Test 1: Basic scheduling
2776        let task1 =
2777            create_mock_task_for_schedule(&env, file_id1, region_id, IndexBuildType::Flush).await;
2778        scheduler.schedule_build(&version_control, task1).await;
2779        assert!(scheduler.region_status.contains_key(&region_id));
2780        let status = scheduler.region_status.get(&region_id).unwrap();
2781        assert_eq!(status.building_files.len(), 1);
2782        assert!(status.building_files.contains(&file_id1));
2783
2784        // Test 2: Duplicate file scheduling (should be skipped)
2785        let task1_dup =
2786            create_mock_task_for_schedule(&env, file_id1, region_id, IndexBuildType::Flush).await;
2787        scheduler.schedule_build(&version_control, task1_dup).await;
2788        let status = scheduler.region_status.get(&region_id).unwrap();
2789        assert_eq!(status.building_files.len(), 1); // Still only one
2790
2791        // Test 3: Fill up to limit (2 building tasks)
2792        let task2 =
2793            create_mock_task_for_schedule(&env, file_id2, region_id, IndexBuildType::Flush).await;
2794        scheduler.schedule_build(&version_control, task2).await;
2795        let status = scheduler.region_status.get(&region_id).unwrap();
2796        assert_eq!(status.building_files.len(), 2); // Reached limit
2797        assert_eq!(status.pending_tasks.len(), 0);
2798
2799        // Test 4: Add tasks with different priorities to pending queue
2800        // Now all new tasks will be pending since we reached the limit
2801        let task3 =
2802            create_mock_task_for_schedule(&env, file_id3, region_id, IndexBuildType::Compact).await;
2803        let task4 =
2804            create_mock_task_for_schedule(&env, file_id4, region_id, IndexBuildType::SchemaChange)
2805                .await;
2806        let task5 =
2807            create_mock_task_for_schedule(&env, file_id5, region_id, IndexBuildType::Manual).await;
2808
2809        scheduler.schedule_build(&version_control, task3).await;
2810        scheduler.schedule_build(&version_control, task4).await;
2811        scheduler.schedule_build(&version_control, task5).await;
2812
2813        let status = scheduler.region_status.get(&region_id).unwrap();
2814        assert_eq!(status.building_files.len(), 2); // Still at limit
2815        assert_eq!(status.pending_tasks.len(), 3); // Three pending
2816
2817        // Test 5: Task completion triggers scheduling next highest priority task (Manual)
2818        scheduler.on_task_stopped(region_id, file_id1);
2819        let status = scheduler.region_status.get(&region_id).unwrap();
2820        assert!(!status.building_files.contains(&file_id1));
2821        assert_eq!(status.building_files.len(), 2); // Should schedule next task
2822        assert_eq!(status.pending_tasks.len(), 2); // One less pending
2823        // The highest priority task (Manual) should now be building
2824        assert!(status.building_files.contains(&file_id5));
2825
2826        // Test 6: Complete another task, should schedule SchemaChange (second highest priority)
2827        scheduler.on_task_stopped(region_id, file_id2);
2828        let status = scheduler.region_status.get(&region_id).unwrap();
2829        assert_eq!(status.building_files.len(), 2);
2830        assert_eq!(status.pending_tasks.len(), 1); // One less pending
2831        assert!(status.building_files.contains(&file_id4)); // SchemaChange should be building
2832
2833        // Test 7: Complete remaining tasks and cleanup
2834        scheduler.on_task_stopped(region_id, file_id5);
2835        scheduler.on_task_stopped(region_id, file_id4);
2836
2837        let status = scheduler.region_status.get(&region_id).unwrap();
2838        assert_eq!(status.building_files.len(), 1); // Last task (Compact) should be building
2839        assert_eq!(status.pending_tasks.len(), 0);
2840        assert!(status.building_files.contains(&file_id3));
2841
2842        scheduler.on_task_stopped(region_id, file_id3);
2843
2844        // Region should be removed when all tasks complete
2845        assert!(!scheduler.region_status.contains_key(&region_id));
2846    }
2847
2848    /// Helper to set up a scheduler with files_limit=1 and 3 scheduled tasks,
2849    /// returning the scheduler, the two pending-task result receivers, and the
2850    /// version control.
2851    async fn setup_scheduler_with_pending_tasks(
2852        env: &SchedulerEnv,
2853    ) -> (
2854        IndexBuildScheduler,
2855        mpsc::Receiver<Result<IndexBuildOutcome>>,
2856        mpsc::Receiver<Result<IndexBuildOutcome>>,
2857        VersionControlRef,
2858        RegionId,
2859        FileId, // building file_id for no-op assertion
2860    ) {
2861        let metadata = Arc::new(sst_region_metadata());
2862        let region_id = metadata.region_id;
2863        let file_purger = Arc::new(NoopFilePurger {});
2864
2865        let file_id1 = FileId::random();
2866        let file_id2 = FileId::random();
2867        let file_id3 = FileId::random();
2868
2869        let files = HashMap::from([
2870            (
2871                file_id1,
2872                FileMeta {
2873                    region_id,
2874                    file_id: file_id1,
2875                    file_size: 100,
2876                    ..Default::default()
2877                },
2878            ),
2879            (
2880                file_id2,
2881                FileMeta {
2882                    region_id,
2883                    file_id: file_id2,
2884                    file_size: 100,
2885                    ..Default::default()
2886                },
2887            ),
2888            (
2889                file_id3,
2890                FileMeta {
2891                    region_id,
2892                    file_id: file_id3,
2893                    file_size: 100,
2894                    ..Default::default()
2895                },
2896            ),
2897        ]);
2898        let version_control =
2899            mock_version_control(metadata.clone(), file_purger.clone(), files).await;
2900
2901        let mut scheduler = env.mock_index_build_scheduler(1);
2902
2903        // task1 becomes the "building" task (files_limit=1).
2904        // We intentionally drop its result receiver: the building task's late-stop
2905        // behavior is covered by the manual on_task_stopped no-op assertion below.
2906        let (task1, _rx1) = create_mock_task_for_schedule_with_result(
2907            env,
2908            file_id1,
2909            region_id,
2910            IndexBuildType::Flush,
2911        )
2912        .await;
2913        let (task2, rx2) = create_mock_task_for_schedule_with_result(
2914            env,
2915            file_id2,
2916            region_id,
2917            IndexBuildType::Flush,
2918        )
2919        .await;
2920        let (task3, rx3) = create_mock_task_for_schedule_with_result(
2921            env,
2922            file_id3,
2923            region_id,
2924            IndexBuildType::Flush,
2925        )
2926        .await;
2927
2928        scheduler.schedule_build(&version_control, task1).await;
2929        scheduler.schedule_build(&version_control, task2).await;
2930        scheduler.schedule_build(&version_control, task3).await;
2931
2932        // Verify: 1 building + 2 pending.
2933        assert!(scheduler.region_status.contains_key(&region_id));
2934        let status = scheduler.region_status.get(&region_id).unwrap();
2935        assert_eq!(status.building_files.len(), 1);
2936        assert_eq!(status.pending_tasks.len(), 2);
2937
2938        (scheduler, rx2, rx3, version_control, region_id, file_id1)
2939    }
2940
2941    /// Pattern‑matches a pending‑task cancellation error: outer **must** be
2942    /// [`crate::error::Error::BuildIndexAsync`] and the inner source **must** be
2943    /// the lifecycle variant named by `expected_source` (`"dropped"`, `"closed"`,
2944    /// or `"truncated"`).  Panics with a descriptive message on mismatch.
2945    fn assert_lifecycle_error(
2946        err: crate::error::Error,
2947        expected_source: &str,
2948        lifecycle_name: &str,
2949    ) {
2950        let crate::error::Error::BuildIndexAsync { source, .. } = err else {
2951            panic!("[{lifecycle_name}] Expected BuildIndexAsync outer error, got: {err:?}");
2952        };
2953        let actual = match &*source {
2954            crate::error::Error::RegionDropped { .. } => "dropped",
2955            crate::error::Error::RegionClosed { .. } => "closed",
2956            crate::error::Error::RegionTruncated { .. } => "truncated",
2957            other => panic!(
2958                "[{lifecycle_name}] Expected lifecycle source variant (RegionDropped/RegionClosed/RegionTruncated), got: {other:?}"
2959            ),
2960        };
2961        assert_eq!(
2962            actual, expected_source,
2963            "[{lifecycle_name}] Source variant mismatch"
2964        );
2965    }
2966
2967    /// Receives a pending‑task error from `rx` with a 5‑second timeout, then
2968    /// delegates to [`assert_lifecycle_error`].
2969    async fn recv_lifecycle_error(
2970        rx: &mut mpsc::Receiver<Result<IndexBuildOutcome>>,
2971        expected_source: &str,
2972        lifecycle_name: &str,
2973    ) {
2974        let result = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
2975            .await
2976            .unwrap_or_else(|_| {
2977                panic!(
2978                    "[{lifecycle_name}] Timeout (5s) waiting for lifecycle error from pending task"
2979                )
2980            });
2981        let result = result.unwrap_or_else(|| {
2982            panic!("[{lifecycle_name}] Channel closed without receiving lifecycle error")
2983        });
2984        let err = result.unwrap_err();
2985        assert_lifecycle_error(err, expected_source, lifecycle_name);
2986    }
2987
2988    #[tokio::test]
2989    async fn test_scheduler_lifecycle_cleanup() {
2990        let env = SchedulerEnv::new().await;
2991
2992        // --- on_region_dropped ---
2993        {
2994            let (mut scheduler, mut rx2, mut rx3, _version_control, region_id, building_file_id) =
2995                setup_scheduler_with_pending_tasks(&env).await;
2996
2997            scheduler.on_region_dropped(region_id).await;
2998
2999            let status = scheduler.region_status.get(&region_id).unwrap();
3000            assert!(status.retiring);
3001            assert_eq!(status.building_files.len(), 1);
3002            assert!(status.pending_tasks.is_empty());
3003
3004            // Pending-task receivers get lifecycle errors (with timeout).
3005            recv_lifecycle_error(&mut rx2, "dropped", "on_region_dropped").await;
3006            recv_lifecycle_error(&mut rx3, "dropped", "on_region_dropped").await;
3007
3008            // The active build keeps its lease until it stops.
3009            scheduler.on_task_stopped(region_id, building_file_id);
3010            assert!(!scheduler.region_status.contains_key(&region_id));
3011        }
3012
3013        // --- on_region_closed ---
3014        {
3015            let (mut scheduler, mut rx2, mut rx3, version_control, region_id, building_file_id) =
3016                setup_scheduler_with_pending_tasks(&env).await;
3017
3018            scheduler.on_region_closed(region_id).await;
3019
3020            let status = scheduler.region_status.get(&region_id).unwrap();
3021            assert!(status.retiring);
3022            assert_eq!(status.building_files.len(), 1);
3023            assert!(status.pending_tasks.is_empty());
3024
3025            recv_lifecycle_error(&mut rx2, "closed", "on_region_closed").await;
3026            recv_lifecycle_error(&mut rx3, "closed", "on_region_closed").await;
3027
3028            // A build scheduled by the reopened region waits behind the old
3029            // incarnation's active lease.
3030            let (reopened_task, _reopened_rx) = create_mock_task_for_schedule_with_result(
3031                &env,
3032                building_file_id,
3033                region_id,
3034                IndexBuildType::Manual,
3035            )
3036            .await;
3037            scheduler
3038                .schedule_build(&version_control, reopened_task)
3039                .await;
3040            let status = scheduler.region_status.get(&region_id).unwrap();
3041            assert!(status.retiring);
3042            assert_eq!(status.building_files.len(), 1);
3043            assert_eq!(status.pending_tasks.len(), 1);
3044
3045            scheduler.on_task_stopped(region_id, building_file_id);
3046            let status = scheduler.region_status.get(&region_id).unwrap();
3047            assert!(!status.retiring);
3048            assert_eq!(status.building_files.len(), 1);
3049            assert!(status.pending_tasks.is_empty());
3050
3051            scheduler.on_task_stopped(region_id, building_file_id);
3052            assert!(!scheduler.region_status.contains_key(&region_id));
3053        }
3054
3055        // --- on_region_truncated ---
3056        {
3057            let (mut scheduler, mut rx2, mut rx3, _version_control, region_id, building_file_id) =
3058                setup_scheduler_with_pending_tasks(&env).await;
3059
3060            scheduler.on_region_truncated(region_id).await;
3061
3062            let status = scheduler.region_status.get(&region_id).unwrap();
3063            assert!(status.retiring);
3064            assert_eq!(status.building_files.len(), 1);
3065            assert!(status.pending_tasks.is_empty());
3066
3067            recv_lifecycle_error(&mut rx2, "truncated", "on_region_truncated").await;
3068            recv_lifecycle_error(&mut rx3, "truncated", "on_region_truncated").await;
3069
3070            scheduler.on_task_stopped(region_id, building_file_id);
3071            assert!(!scheduler.region_status.contains_key(&region_id));
3072        }
3073    }
3074}