indexlake 0.6.0

An experimental table format with extensible index support.
Documentation
use std::{
    collections::{BTreeMap, HashSet},
    time::Duration,
};

use arrow::array::RecordBatch;
use futures::StreamExt;
use log::debug;
use uuid::Uuid;

use crate::{
    ILError, ILResult, RecordBatchStream,
    catalog::{DataFileRecord, IndexFileRecord, RowValidity, TransactionHelper},
    index::IndexBuilder,
    storage::{EntryMode, build_parquet_writer, read_data_file_by_record},
    table::{Table, dump::rebuild_inline_indexes, insert_task},
    utils::extract_row_ids_from_record_batch,
};

#[derive(Debug, Clone)]
pub enum TableOptimization {
    CleanupOrphanFiles { last_modified_before: i64 },
    MergeDataFiles { valid_row_threshold: usize },
    RebuildInlineIndexes { index_names: Option<Vec<String>> },
}

pub(crate) async fn process_table_optimization(
    table: &Table,
    optimization: TableOptimization,
) -> ILResult<()> {
    match optimization {
        TableOptimization::CleanupOrphanFiles {
            last_modified_before,
        } => cleanup_orphan_files(table, last_modified_before).await,
        TableOptimization::MergeDataFiles {
            valid_row_threshold,
        } => merge_data_files(table, valid_row_threshold).await,
        TableOptimization::RebuildInlineIndexes { index_names } => {
            let index_ids = index_names
                .as_ref()
                .map(|names| {
                    names
                        .iter()
                        .map(|name| {
                            table
                                .index_manager
                                .get_index(name)
                                .map(|def| def.index_id)
                                .ok_or_else(|| {
                                    ILError::invalid_input(format!("Index not found: {name}"))
                                })
                        })
                        .collect::<ILResult<Vec<_>>>()
                })
                .transpose()?;
            rebuild_inline_indexes_from_scratch(table, index_ids.as_deref()).await
        }
    }
}

async fn rebuild_inline_indexes_from_scratch(
    table: &Table,
    index_ids: Option<&[Uuid]>,
) -> ILResult<()> {
    let task_id = format!("rebuild-inline-indexes-{}", table.table_id);
    if insert_task(
        &table.catalog,
        task_id.clone(),
        Duration::from_secs(24 * 60 * 60),
    )
    .await
    .is_err()
    {
        debug!(
            "[indexlake] Table {} already has a rebuild inline indexes task",
            table.table_id
        );
        return Ok(());
    }

    let mut tx_helper = TransactionHelper::new(&table.catalog).await?;
    rebuild_inline_indexes(
        &mut tx_helper,
        &table.table_id,
        &table.table_schema,
        &table.index_manager,
        index_ids,
    )
    .await?;

    tx_helper.delete_task(&task_id).await?;
    tx_helper.commit().await?;
    Ok(())
}

async fn cleanup_orphan_files(table: &Table, last_modified_before: i64) -> ILResult<()> {
    let task_id = format!("cleanup-orphan-files-{}", table.table_id);
    if insert_task(
        &table.catalog,
        task_id.clone(),
        Duration::from_secs(24 * 60 * 60),
    )
    .await
    .is_err()
    {
        debug!(
            "[indexlake] Table {} already has a cleanup orphan files task",
            table.table_id
        );
        return Ok(());
    }

    let mut tx_helper = TransactionHelper::new(&table.catalog).await?;
    let data_files = tx_helper.get_data_files(&table.table_id).await?;
    let index_files = tx_helper.get_table_index_files(&table.table_id).await?;
    let existing_files = data_files
        .iter()
        .map(|f| &f.relative_path)
        .chain(index_files.iter().map(|f| &f.relative_path))
        .collect::<HashSet<_>>();

    let table_dir = format!("{}/{}", table.namespace_id, table.table_id);
    let mut dir_entry_stream = table.storage.list(&table_dir).await?;
    while let Some(entry) = dir_entry_stream.next().await {
        let entry = entry?;
        if matches!(entry.metadata.mode, EntryMode::File)
            && let Some(last_modified) = entry.metadata.last_modified
            && last_modified < last_modified_before
        {
            let relative_path = format!("{}/{}", table_dir, entry.name);
            if !existing_files.contains(&relative_path) {
                table.storage.delete(&relative_path).await?;
            }
        }
    }

    tx_helper.delete_task(&task_id).await?;

    tx_helper.commit().await?;

    Ok(())
}

async fn merge_data_files(table: &Table, valid_row_threshold: usize) -> ILResult<()> {
    let task_id = format!("merge-data-files-{}", table.table_id);
    if insert_task(
        &table.catalog,
        task_id.clone(),
        Duration::from_secs(24 * 60 * 60),
    )
    .await
    .is_err()
    {
        debug!(
            "[indexlake] Table {} already has a merge data files task",
            table.table_id
        );
        return Ok(());
    }

    let mut tx_helper = TransactionHelper::new(&table.catalog).await?;
    let data_file_records = tx_helper.get_data_files(&table.table_id).await?;
    let matched_data_files = data_file_records
        .into_iter()
        .filter(|record| record.valid_row_count() < valid_row_threshold)
        .collect::<Vec<_>>();

    let mut merge_file_groups = Vec::with_capacity(matched_data_files.len() / 2);
    let mut file_group = Vec::with_capacity(matched_data_files.len());
    let mut file_group_count = 0;
    for (index, data_file) in matched_data_files.iter().enumerate() {
        file_group.push(index);
        file_group_count += data_file.valid_row_count();
        if file_group_count >= table.config.inline_row_count_limit {
            merge_file_groups.push(file_group.clone());
            file_group.clear();
            file_group_count = 0;
        }
    }

    let mut new_data_files = Vec::with_capacity(merge_file_groups.len());
    let mut index_file_records = Vec::new();
    for group in merge_file_groups.iter() {
        let files = group
            .iter()
            .map(|i| &matched_data_files[*i])
            .collect::<Vec<_>>();

        let mut index_builders = table.index_manager.new_index_builders(None)?;

        let data_file_id = Uuid::now_v7();
        let relative_path = DataFileRecord::build_relative_path(
            &table.namespace_id,
            &table.table_id,
            &data_file_id,
            table.config.preferred_data_file_format,
        );
        stream_merge_group_files(table, &files, &relative_path, &mut index_builders).await?;

        let record_count: usize = files.iter().map(|f| f.valid_row_count()).sum();
        let size = table
            .storage
            .open(&relative_path)
            .await?
            .metadata()
            .await?
            .size;

        new_data_files.push(DataFileRecord {
            data_file_id,
            table_id: table.table_id,
            format: table.config.preferred_data_file_format,
            relative_path,
            size: size as i64,
            record_count: record_count as i64,
            valid_record_count: record_count as i64,
            validity: RowValidity::new(record_count),
        });

        for index_builder in index_builders.iter_mut() {
            if index_builder.is_empty() {
                continue;
            }
            let index_file_id = Uuid::now_v7();
            let relative_path = IndexFileRecord::build_relative_path(
                &table.namespace_id,
                &table.table_id,
                &index_file_id,
            );
            let output_file = table.storage.create(&relative_path).await?;
            index_builder.write_file(output_file).await?;
            let size = table
                .storage
                .open(&relative_path)
                .await?
                .metadata()
                .await?
                .size;
            index_file_records.push(IndexFileRecord {
                index_file_id,
                table_id: table.table_id,
                index_id: index_builder.index_def().index_id,
                data_file_id,
                relative_path,
                size: size as i64,
            });
        }
    }

    let delete_data_file_ids = merge_file_groups
        .iter()
        .flatten()
        .map(|i| matched_data_files[*i].data_file_id)
        .collect::<Vec<_>>();
    let delete_count = tx_helper.delete_data_files(&delete_data_file_ids).await?;
    if delete_count != delete_data_file_ids.len() {
        return Err(ILError::internal(format!(
            "Delete data file count mismatched,delete count: {delete_count}, data file ids: {delete_data_file_ids:?}",
        )));
    }

    tx_helper
        .delete_index_files_by_data_file_ids(&delete_data_file_ids)
        .await?;

    tx_helper.insert_data_files(&new_data_files).await?;
    tx_helper.insert_index_files(&index_file_records).await?;

    tx_helper.delete_task(&task_id).await?;

    tx_helper.commit().await?;

    debug!(
        "[indexlake] Successfully merged {} data files to {} for table {}",
        delete_data_file_ids.len(),
        new_data_files.len(),
        table.table_id
    );

    Ok(())
}

async fn stream_merge_group_files(
    table: &Table,
    files: &[&DataFileRecord],
    relative_path: &str,
    index_builders: &mut Vec<Box<dyn IndexBuilder>>,
) -> ILResult<()> {
    let output_file = table.storage.create(relative_path).await?;
    let mut parquet_writer = build_parquet_writer(
        output_file,
        table.table_schema.arrow_schema.clone(),
        table.config.parquet_row_group_size,
        table.config.preferred_data_file_format,
    )?;

    let mut streams: Vec<RecordBatchStream> = Vec::with_capacity(files.len());
    for file in files {
        let stream = read_data_file_by_record(
            table.storage.as_ref(),
            &table.table_schema,
            file,
            None,
            vec![],
            1,
        )
        .await?;
        streams.push(stream);
    }

    let mut stream_map: BTreeMap<Uuid, (usize, RecordBatch)> = BTreeMap::new();
    for (index, stream) in streams.iter_mut().enumerate() {
        if let Some(batch) = stream.next().await {
            let batch = batch?;
            let row_ids = extract_row_ids_from_record_batch(&batch)?;
            debug_assert_eq!(row_ids.len(), 1);
            stream_map.insert(row_ids[0], (index, batch));
        }
    }

    loop {
        if let Some((_, (stream_index, batch))) = stream_map.pop_first() {
            parquet_writer.write(&batch).await?;
            for builder in index_builders.iter_mut() {
                builder.append(&batch)?;
            }

            if let Some(batch) = streams[stream_index].next().await {
                let batch = batch?;
                let row_ids = extract_row_ids_from_record_batch(&batch)?;
                debug_assert_eq!(row_ids.len(), 1);
                stream_map.insert(row_ids[0], (stream_index, batch));
            }
        }

        if stream_map.is_empty() {
            break;
        }
    }

    parquet_writer.close().await?;

    Ok(())
}