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