timeseries-table-format 0.7.0

Append-only time-series table format with gap/overlap tracking
Documentation
use arrow::error::ArrowError;
use parquet::errors::ParquetError;
use std::path::PathBuf;
use timeseries_table_format::metadata::index::ParseTimeIndexGranularityError;
use timeseries_table_format::table::TableError;

use snafu::Snafu;

pub type CliResult<T> = std::result::Result<T, CliError>;

#[derive(Debug, Snafu)]
#[snafu(visibility(pub(crate)))]
pub enum CliError {
    #[snafu(display("Failed to initialize CLI diagnostics: {reason}"))]
    DiagnosticsInitialization { reason: String },

    #[snafu(display("Invalid --index-granularity '{spec}' for --index-type timestamp: {source}"))]
    InvalidTimeIndexGranularity {
        spec: String,
        source: ParseTimeIndexGranularityError,
    },

    #[snafu(display("Invalid {option} for --index-type {index_type}: {reason}"))]
    InvalidIndexOption {
        option: &'static str,
        index_type: &'static str,
        reason: String,
    },

    #[snafu(display(
        "Failed to create timeseries table at {table}. \
         Ensure the directory is writable."
    ))]
    CreateTable {
        table: String,
        #[snafu(source(from(TableError, Box::new)), backtrace)]
        source: Box<TableError>,
    },

    #[snafu(display(
        "Failed to open time-series table at {table}. \
         Ensure it is a valid timeseries-table-format table."
    ))]
    OpenTable {
        table: String,
        #[snafu(source(from(TableError, Box::new)), backtrace)]
        source: Box<TableError>,
    },

    #[snafu(display("Failed to read Parquet source {path}: {source}"))]
    ReadParquetSource { path: String, source: ParquetError },

    #[snafu(display(
        "Append failed for Parquet source {parquet} into table {table}. \
         Ensure schema matches the table and the parquet is valid: {source}"
    ))]
    AppendSegment {
        table: String,
        parquet: String,
        #[snafu(source(from(TableError, Box::new)), backtrace)]
        source: Box<TableError>,
    },

    #[snafu(display("Entity-layout optimization failed for table {table}: {source}"))]
    OptimizeTable {
        table: String,
        #[snafu(source(from(TableError, Box::new)), backtrace)]
        source: Box<TableError>,
    },

    #[snafu(display("Vacuum failed for table {table}: {source}"))]
    VacuumTable {
        table: String,
        #[snafu(source(from(TableError, Box::new)), backtrace)]
        source: Box<TableError>,
    },

    #[snafu(display("Internal path error: {message}"))]
    PathInvariantNoSource {
        message: String,
        path: Option<PathBuf>,
    },

    #[snafu(display("Storage error: {source}"))]
    Storage {
        #[snafu(backtrace)]
        source: timeseries_table_format::storage::StorageError,
    },

    #[snafu(display("DataFusion error: {source}"))]
    DataFusion {
        source: datafusion::error::DataFusionError,
    },

    #[snafu(display("Arrow error: {source}"))]
    Arrow { source: ArrowError },

    #[snafu(display(
        "CSV output does not support nested field '{field}' with type {data_type}. \
         Use --format jsonl instead."
    ))]
    CsvUnsupportedType { field: String, data_type: String },
}

#[cfg(test)]
mod tests {
    use std::error::Error as _;

    use arrow::error::ArrowError;
    use snafu::{Backtrace, ErrorCompat};
    use timeseries_table_format::{
        coverage::CoverageSidecarError,
        storage::StorageLocation,
        table::{AppendError, CoverageQueryError, OpenTableError, TableError},
    };

    use super::CliError;

    #[test]
    fn datafusion_cli_error_preserves_coverage_query_hierarchy() {
        let storage = StorageLocation::parse("").expect_err("empty location must fail");
        let error = CliError::DataFusion {
            source: datafusion::error::DataFusionError::External(Box::new(
                TableError::CoverageQuery {
                    source: CoverageQueryError::CoverageSnapshotRead {
                        coverage_path: "_coverage/table/missing.roar".to_string(),
                        source: Box::new(CoverageSidecarError::Storage { source: storage }),
                    },
                },
            )),
        };

        let datafusion = error
            .source()
            .and_then(|source| source.downcast_ref::<datafusion::error::DataFusionError>())
            .expect("DataFusion source");
        let table = datafusion
            .source()
            .and_then(|source| source.downcast_ref::<TableError>())
            .expect("table source");
        assert!(matches!(
            table.source()
                .and_then(|source| source.downcast_ref::<CoverageQueryError>()),
            Some(CoverageQueryError::CoverageSnapshotRead {
                coverage_path: path,
                ..
            })
                if path == "_coverage/table/missing.roar"
        ));
    }

    #[test]
    fn table_cli_error_delegates_the_table_backtrace() {
        let table = TableError::Append {
            source: AppendError::ArrowInput {
                source: ArrowError::ComputeError("input failed".to_string()),
                backtrace: Backtrace::capture(),
            },
        };
        let error = CliError::AppendSegment {
            table: "table".to_string(),
            parquet: "input.parquet".to_string(),
            source: Box::new(table),
        };
        let table = error
            .source()
            .and_then(|source| source.downcast_ref::<Box<TableError>>())
            .expect("table source");
        let table_backtrace = ErrorCompat::backtrace(table).expect("table backtrace");

        assert!(std::ptr::eq(
            ErrorCompat::backtrace(&error).expect("CLI backtrace"),
            table_backtrace
        ));
    }

    #[test]
    fn open_error_message_does_not_name_an_obsolete_format_version() {
        let error = CliError::OpenTable {
            table: "table".to_string(),
            source: Box::new(TableError::Open {
                source: OpenTableError::EmptyTable,
            }),
        };
        let message = error.to_string();

        assert!(message.contains("Failed to open time-series table"));
        assert!(!message.contains("v0.1"));
    }
}