Skip to main content

cmd/datanode/
parquetbench.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
15use std::path::PathBuf;
16use std::sync::Arc;
17use std::time::{Duration, Instant};
18
19use clap::{Parser, ValueEnum};
20use colored::Colorize;
21use datatypes::arrow::datatypes::{DataType as ArrowDataType, Field, Schema, SchemaRef};
22use futures::StreamExt;
23use mito2::cache::CacheStrategy;
24use mito2::read::range::FileRangeBuilder;
25use mito2::read::read_columns::ReadColumns;
26use mito2::sst::file::{FileHandle, FileMeta, RegionFileId};
27use mito2::sst::file_purger::NoopFilePurger;
28use mito2::sst::location::sst_file_path;
29use mito2::sst::parquet::metadata::MetadataLoader;
30use mito2::sst::parquet::push_decoder::{
31    SstParquetRangeFetcher, build_sst_parquet_record_batch_stream,
32};
33use mito2::sst::parquet::reader::{MetadataCacheMetrics, ParquetReaderBuilder, ReaderMetrics};
34use mito2::sst::{FlatSchemaOptions, to_flat_sst_arrow_schema};
35use parquet::arrow::ProjectionMask;
36use parquet::arrow::arrow_reader::{ArrowReaderMetadata, ArrowReaderOptions};
37use serde::Deserialize;
38use smallvec::SmallVec;
39use snafu::ResultExt;
40use store_api::metadata::{RegionMetadata, RegionMetadataRef};
41use store_api::region_request::PathType;
42use store_api::storage::consts::{PRIMARY_KEY_COLUMN_NAME, is_internal_column};
43use store_api::storage::{ColumnId, FileId, RegionId};
44
45use crate::datanode::objbench::{build_object_store, extract_region_metadata, parse_config};
46use crate::error;
47
48const DEFAULT_READ_BATCH_SIZE: usize = 8 * 1024;
49
50/// Parquet benchmark command - benchmarks scanning a single parquet SST directly.
51#[derive(Debug, Parser)]
52pub struct ParquetbenchCommand {
53    /// Path to config TOML file (same format as standalone/datanode config)
54    #[clap(long, value_name = "FILE")]
55    config: PathBuf,
56
57    /// Region ID: either numeric u64 (e.g. "4398046511104") or "table_id:region_num" (e.g. "1024:0")
58    #[clap(long)]
59    region_id: String,
60
61    /// Table directory relative to data home (e.g. "data/greptime/public/1024/")
62    #[clap(long)]
63    table_dir: String,
64
65    /// SST file id to benchmark.
66    #[clap(long)]
67    file_id: String,
68
69    /// Path to scan request JSON config file (supports projection_names only)
70    #[clap(long, value_name = "FILE")]
71    scan_config: Option<PathBuf>,
72
73    /// Number of iterations for benchmarking
74    #[clap(long, default_value = "1")]
75    iterations: usize,
76
77    /// Number of rows per record batch for the direct reader.
78    #[clap(long, default_value_t = DEFAULT_READ_BATCH_SIZE, value_parser = parse_batch_size)]
79    batch_size: usize,
80
81    /// Path type for the region: bare, data, metadata
82    #[clap(long, default_value = "bare")]
83    path_type: String,
84
85    /// Verbose output
86    #[clap(short, long, default_value_t = false)]
87    verbose: bool,
88
89    /// Output pprof flamegraph
90    #[clap(long, value_name = "FILE")]
91    pprof_file: Option<PathBuf>,
92
93    /// Start pprof after the first iteration (use first iteration as warmup).
94    #[clap(long, default_value_t = false)]
95    pprof_after_warmup: bool,
96
97    /// Read the `__primary_key` column as BinaryArray instead of DictionaryArray.
98    /// Only affects the `direct` reader.
99    #[clap(long, default_value_t = false)]
100    pk_as_binary: bool,
101
102    /// Reader implementation to benchmark.
103    #[clap(long, value_enum, default_value = "direct")]
104    reader: ReaderMode,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
108enum ReaderMode {
109    /// Read directly via the push-decoder parquet stream.
110    Direct,
111    /// Read via ParquetReaderBuilder, FileRange, and FlatPruneReader.
112    FlatPrune,
113}
114
115#[derive(Debug, Deserialize, Default)]
116struct ParquetScanConfig {
117    projection_names: Option<Vec<String>>,
118    row_groups: Option<Vec<usize>>,
119}
120
121#[derive(Debug, Default, Clone)]
122struct IterationStats {
123    rows: usize,
124    record_batches: usize,
125    /// Number of columns in the output record batches.
126    columns: usize,
127    /// Schema of the output record batches, captured from the first batch.
128    schema: Option<SchemaRef>,
129    elapsed: Duration,
130}
131
132impl ParquetbenchCommand {
133    pub async fn run(&self) -> error::Result<()> {
134        if self.verbose {
135            common_telemetry::init_default_ut_logging();
136        }
137
138        println!("{}", "Starting parquetbench...".cyan().bold());
139
140        if self.iterations <= 1 && self.pprof_after_warmup && self.pprof_file.is_some() {
141            return error::IllegalConfigSnafu {
142                msg: "pprof-after-warmup requires at least 2 iterations (1 warmup + 1 profiled)"
143                    .to_string(),
144            }
145            .fail();
146        }
147
148        let region_id = parse_region_id(&self.region_id)?;
149        let path_type = parse_path_type(&self.path_type)?;
150        let file_id = FileId::parse_str(&self.file_id).map_err(|e| {
151            error::IllegalConfigSnafu {
152                msg: format!("invalid file_id '{}': {}", self.file_id, e),
153            }
154            .build()
155        })?;
156        let region_file_id = RegionFileId::new(region_id, file_id);
157        let file_path = sst_file_path(&self.table_dir, region_file_id, path_type);
158
159        let (store_cfg, _mito_config, _wal_config) = parse_config(&self.config)?;
160        let object_store = build_object_store(&store_cfg).await?;
161
162        let file_size = object_store
163            .stat(&file_path)
164            .await
165            .map_err(|e| {
166                error::IllegalConfigSnafu {
167                    msg: format!("stat failed for {}: {}", file_path, e),
168                }
169                .build()
170            })?
171            .content_length();
172        let mut metadata_metrics = MetadataCacheMetrics::default();
173        let parquet_meta = MetadataLoader::new(object_store.clone(), &file_path, file_size)
174            .load(&mut metadata_metrics)
175            .await
176            .map_err(|e| {
177                error::IllegalConfigSnafu {
178                    msg: format!("read parquet metadata failed for {}: {:?}", file_path, e),
179                }
180                .build()
181            })?;
182        let region_meta = extract_region_metadata(&file_path, &parquet_meta)?;
183        let scan_config = self.load_scan_config().await?;
184        let projection = if self.reader == ReaderMode::Direct {
185            resolve_projection_names(&scan_config, &region_meta)?
186        } else {
187            None
188        };
189        let projection_column_ids = if self.reader == ReaderMode::FlatPrune {
190            resolve_projection_column_ids(&scan_config, &region_meta)?
191        } else {
192            None
193        };
194        let row_groups = resolve_row_groups(&scan_config, parquet_meta.num_row_groups())?;
195        let read_all_row_groups = scan_config.row_groups.is_none();
196        let scanned_bytes = scanned_row_group_bytes(&parquet_meta, &row_groups);
197        let projected_columns = projection_names_display(&scan_config);
198        let row_groups_display = row_groups_display(&row_groups, parquet_meta.num_row_groups());
199        let mut sst_schema = to_flat_sst_arrow_schema(
200            &region_meta,
201            &FlatSchemaOptions::from_encoding(region_meta.primary_key_encoding),
202        );
203        if self.pk_as_binary {
204            sst_schema = override_pk_to_binary(&sst_schema);
205        }
206
207        println!(
208            "{} Reader: {}",
209            "✓".green(),
210            match self.reader {
211                ReaderMode::Direct => "direct",
212                ReaderMode::FlatPrune => "flat-prune",
213            }
214            .cyan()
215        );
216        println!(
217            "{} Region ID: {} (u64: {})",
218            "✓".green(),
219            self.region_id,
220            region_id.as_u64()
221        );
222        println!("{} File path: {}", "✓".green(), file_path.cyan());
223        println!(
224            "{} Columns: {}",
225            "✓".green(),
226            projected_columns.as_deref().unwrap_or("all columns").cyan()
227        );
228        println!("{} Row groups: {}", "✓".green(), row_groups_display.cyan());
229        if !read_all_row_groups {
230            println!(
231                "{} Scanned bytes (selected row groups): {}",
232                "✓".green(),
233                format_bytes(scanned_bytes).cyan()
234            );
235        }
236        println!(
237            "{} __primary_key type: {}",
238            "✓".green(),
239            if self.pk_as_binary {
240                "Binary"
241            } else {
242                "Dictionary(UInt32, Binary)"
243            }
244            .cyan()
245        );
246        match self.reader {
247            ReaderMode::Direct => {
248                println!("{} Batch size: {}", "✓".green(), self.batch_size);
249            }
250            ReaderMode::FlatPrune => {
251                println!(
252                    "{} Batch size: {} (flat-prune internal default)",
253                    "✓".green(),
254                    DEFAULT_READ_BATCH_SIZE
255                );
256                println!(
257                    "{} --batch-size is only used by the direct reader; ignoring {} in flat-prune mode",
258                    "ℹ".blue(),
259                    self.batch_size
260                );
261            }
262        }
263        println!(
264            "{} Parquet rows: {}, row groups: {}, file size: {}",
265            "✓".green(),
266            parquet_meta.file_metadata().num_rows(),
267            parquet_meta.num_row_groups(),
268            format_bytes(file_size)
269        );
270        println!(
271            "{} Metadata reads: {}, bytes: {}",
272            "✓".green(),
273            metadata_metrics.num_reads,
274            format_bytes(metadata_metrics.bytes_read)
275        );
276
277        #[cfg(unix)]
278        let mut profiler_guard = if self.pprof_file.is_some() && !self.pprof_after_warmup {
279            println!("{} Starting profiling...", "⚡".yellow());
280            Some(
281                pprof::ProfilerGuardBuilder::default()
282                    .frequency(99)
283                    .blocklist(&["libc", "libgcc", "pthread", "vdso"])
284                    .build()
285                    .map_err(|e| {
286                        error::IllegalConfigSnafu {
287                            msg: format!("Failed to start profiler: {e}"),
288                        }
289                        .build()
290                    })?,
291            )
292        } else {
293            None
294        };
295
296        #[cfg(not(unix))]
297        if self.pprof_file.is_some() {
298            eprintln!(
299                "{}: Profiling is not supported on this platform",
300                "Warning".yellow()
301            );
302        }
303
304        let mut total_elapsed_all = Duration::ZERO;
305        let mut total_rows_all = 0usize;
306        let mut total_batches_all = 0usize;
307        let mut schema_printed = false;
308        let file_handle = FileHandle::new(
309            FileMeta {
310                region_id,
311                file_id,
312                time_range: Default::default(),
313                level: 0,
314                file_size,
315                max_row_group_uncompressed_size: 0,
316                available_indexes: Default::default(),
317                indexes: Default::default(),
318                index_file_size: 0,
319                index_version: 0,
320                num_rows: parquet_meta.file_metadata().num_rows() as u64,
321                num_row_groups: parquet_meta.num_row_groups() as u64,
322                sequence: None,
323                partition_expr: None,
324                num_series: 0,
325                primary_key_min: None,
326                primary_key_max: None,
327            },
328            Arc::new(NoopFilePurger),
329        );
330
331        for iteration in 0..self.iterations {
332            let stats = match self.reader {
333                ReaderMode::Direct => {
334                    run_direct_iteration(
335                        object_store.clone(),
336                        file_path.clone(),
337                        region_file_id,
338                        parquet_meta.clone(),
339                        projection.clone(),
340                        row_groups.clone(),
341                        sst_schema.clone(),
342                        self.batch_size,
343                    )
344                    .await?
345                }
346                ReaderMode::FlatPrune => {
347                    run_flat_prune_iteration(
348                        object_store.clone(),
349                        self.table_dir.clone(),
350                        path_type,
351                        file_handle.clone(),
352                        region_meta.clone(),
353                        projection_column_ids.clone(),
354                        row_groups.clone(),
355                        read_all_row_groups,
356                    )
357                    .await?
358                }
359            };
360
361            total_elapsed_all += stats.elapsed;
362            total_rows_all += stats.rows;
363            total_batches_all += stats.record_batches;
364
365            if !schema_printed && let Some(schema) = &stats.schema {
366                println!(
367                    "{} Output schema ({} columns):",
368                    "✓".green(),
369                    schema.fields().len()
370                );
371                for field in schema.fields() {
372                    println!("    - {}: {}", field.name().cyan(), field.data_type());
373                }
374                schema_printed = true;
375            }
376
377            println!(
378                "  Iteration {}: {} rows, {} columns, {} record batches in {:?} ({}/s, {}/s)",
379                iteration + 1,
380                stats.rows,
381                stats.columns,
382                stats.record_batches,
383                stats.elapsed,
384                format_rate(stats.rows as f64 / stats.elapsed.as_secs_f64()),
385                format_bytes_per_sec(scanned_bytes as f64 / stats.elapsed.as_secs_f64()),
386            );
387
388            #[cfg(unix)]
389            if iteration == 0 && self.pprof_after_warmup && self.pprof_file.is_some() {
390                println!("{} Starting profiling after warmup...", "⚡".yellow());
391                profiler_guard = Some(
392                    pprof::ProfilerGuardBuilder::default()
393                        .frequency(99)
394                        .blocklist(&["libc", "libgcc", "pthread", "vdso"])
395                        .build()
396                        .map_err(|e| {
397                            error::IllegalConfigSnafu {
398                                msg: format!("Failed to start profiler: {e}"),
399                            }
400                            .build()
401                        })?,
402                );
403            }
404        }
405
406        #[cfg(unix)]
407        if let (Some(guard), Some(pprof_file)) = (profiler_guard, &self.pprof_file) {
408            println!("{} Generating flamegraph...", "🔥".yellow());
409            match guard.report().build() {
410                Ok(report) => {
411                    let mut flamegraph_data = Vec::new();
412                    if let Err(e) = report.flamegraph(&mut flamegraph_data) {
413                        println!("{}: Failed to generate flamegraph: {}", "Error".red(), e);
414                    } else if let Err(e) = std::fs::write(pprof_file, flamegraph_data) {
415                        println!(
416                            "{}: Failed to write flamegraph to {}: {}",
417                            "Error".red(),
418                            pprof_file.display(),
419                            e
420                        );
421                    } else {
422                        println!(
423                            "{} Flamegraph saved to {}",
424                            "✓".green(),
425                            pprof_file.display().to_string().cyan()
426                        );
427                    }
428                }
429                Err(e) => {
430                    println!("{}: Failed to generate pprof report: {}", "Error".red(), e);
431                }
432            }
433        }
434
435        if self.iterations > 1 {
436            let avg_elapsed = total_elapsed_all / self.iterations as u32;
437            let avg_rows = total_rows_all / self.iterations;
438            let avg_batches = total_batches_all / self.iterations;
439            println!(
440                "\n{} Average: {} rows, {} record batches in {:?} over {} iterations",
441                "ℹ".blue(),
442                avg_rows,
443                avg_batches,
444                avg_elapsed,
445                self.iterations
446            );
447        }
448
449        println!("\n{}", "Benchmark completed!".green().bold());
450        Ok(())
451    }
452
453    async fn load_scan_config(&self) -> error::Result<ParquetScanConfig> {
454        if let Some(path) = &self.scan_config {
455            let content = tokio::fs::read_to_string(path)
456                .await
457                .context(error::FileIoSnafu)?;
458            serde_json::from_str::<ParquetScanConfig>(&content).context(error::SerdeJsonSnafu)
459        } else {
460            Ok(ParquetScanConfig::default())
461        }
462    }
463}
464
465#[allow(clippy::too_many_arguments)]
466async fn run_direct_iteration(
467    object_store: object_store::ObjectStore,
468    file_path: String,
469    region_file_id: RegionFileId,
470    parquet_meta: parquet::file::metadata::ParquetMetaData,
471    projection: Option<Vec<usize>>,
472    row_groups: Vec<usize>,
473    sst_schema: SchemaRef,
474    batch_size: usize,
475) -> error::Result<IterationStats> {
476    let parquet_meta = Arc::new(parquet_meta);
477    let arrow_metadata = ArrowReaderMetadata::try_new(
478        parquet_meta.clone(),
479        ArrowReaderOptions::new().with_schema(sst_schema),
480    )
481    .map_err(|e| {
482        error::IllegalConfigSnafu {
483            msg: format!(
484                "Failed to build parquet arrow metadata for {}: {}",
485                file_path, e
486            ),
487        }
488        .build()
489    })?;
490    let projection_mask = match projection.as_ref() {
491        Some(projection) => {
492            ProjectionMask::roots(arrow_metadata.parquet_schema(), projection.iter().copied())
493        }
494        None => ProjectionMask::all(),
495    };
496    let start = Instant::now();
497    let mut stats = IterationStats::default();
498    for row_group_idx in row_groups {
499        let fetcher = SstParquetRangeFetcher::new(
500            region_file_id,
501            file_path.clone(),
502            object_store.clone(),
503            CacheStrategy::Disabled,
504            row_group_idx,
505            None,
506        );
507        let mut stream = build_sst_parquet_record_batch_stream(
508            arrow_metadata.clone(),
509            row_group_idx,
510            None,
511            projection_mask.clone(),
512            fetcher,
513            file_path.clone(),
514            batch_size,
515        )
516        .map_err(|e| {
517            error::IllegalConfigSnafu {
518                msg: format!(
519                    "Failed to build parquet record batch stream for {}: {e:?}",
520                    file_path
521                ),
522            }
523            .build()
524        })?;
525        while let Some(batch) = stream.next().await.transpose().map_err(|e| {
526            error::IllegalConfigSnafu {
527                msg: format!("Failed to scan parquet file {}: {e:?}", file_path),
528            }
529            .build()
530        })? {
531            stats.rows += batch.num_rows();
532            stats.record_batches += 1;
533            stats.columns = batch.num_columns();
534            if stats.schema.is_none() {
535                stats.schema = Some(batch.schema());
536            }
537        }
538    }
539    stats.elapsed = start.elapsed();
540    Ok(stats)
541}
542
543#[allow(clippy::too_many_arguments)]
544async fn run_flat_prune_iteration(
545    object_store: object_store::ObjectStore,
546    table_dir: String,
547    path_type: PathType,
548    file_handle: FileHandle,
549    region_meta: RegionMetadataRef,
550    projection: Option<Vec<ColumnId>>,
551    row_groups: Vec<usize>,
552    read_all_row_groups: bool,
553) -> error::Result<IterationStats> {
554    let reader_builder = ParquetReaderBuilder::new(table_dir, path_type, file_handle, object_store)
555        .expected_metadata(Some(region_meta))
556        .cache(CacheStrategy::Disabled)
557        .projection(projection.map(ReadColumns::from_deduped_column_ids));
558    let mut reader_metrics = ReaderMetrics::default();
559    let start = Instant::now();
560    let mut stats = IterationStats::default();
561    let Some((context, selection)) = reader_builder
562        .build_reader_input(&mut reader_metrics)
563        .await
564        .map_err(|e| {
565            error::IllegalConfigSnafu {
566                msg: format!("build flat prune reader input failed: {e:?}"),
567            }
568            .build()
569        })?
570    else {
571        stats.elapsed = start.elapsed();
572        return Ok(stats);
573    };
574
575    let range_builder = FileRangeBuilder::new(Arc::new(context), selection);
576    let mut ranges = SmallVec::new();
577    if read_all_row_groups {
578        range_builder.build_ranges(-1, &mut ranges);
579    } else {
580        for row_group_idx in row_groups {
581            range_builder.build_ranges(row_group_idx as i64, &mut ranges);
582        }
583    }
584
585    for range in ranges {
586        let Some(mut reader) = range.flat_reader(None, None).await.map_err(|e| {
587            error::IllegalConfigSnafu {
588                msg: format!("build flat prune reader failed: {e:?}"),
589            }
590            .build()
591        })?
592        else {
593            continue;
594        };
595        while let Some(batch) = reader.next_batch().await.map_err(|e| {
596            error::IllegalConfigSnafu {
597                msg: format!("scan flat prune reader failed: {e:?}"),
598            }
599            .build()
600        })? {
601            stats.rows += batch.num_rows();
602            stats.record_batches += 1;
603            stats.columns = batch.num_columns();
604            if stats.schema.is_none() {
605                stats.schema = Some(batch.schema());
606            }
607        }
608    }
609
610    stats.elapsed = start.elapsed();
611    Ok(stats)
612}
613
614fn resolve_projection_names(
615    scan_config: &ParquetScanConfig,
616    metadata: &RegionMetadata,
617) -> error::Result<Option<Vec<usize>>> {
618    let Some(projection_names) = &scan_config.projection_names else {
619        return Ok(None);
620    };
621
622    let sst_schema = to_flat_sst_arrow_schema(
623        metadata,
624        &FlatSchemaOptions::from_encoding(metadata.primary_key_encoding),
625    );
626    let available_columns = sst_schema
627        .fields()
628        .iter()
629        .map(|field| field.name().as_str())
630        .collect::<Vec<_>>()
631        .join(", ");
632    let projection = projection_names
633        .iter()
634        .map(|name| {
635            sst_schema
636                .column_with_name(name)
637                .map(|x| x.0)
638                .ok_or_else(|| {
639                    error::IllegalConfigSnafu {
640                        msg: format!(
641                            "Unknown column '{}' in projection_names, available columns: [{}]",
642                            name, available_columns
643                        ),
644                    }
645                    .build()
646                })
647        })
648        .collect::<error::Result<Vec<_>>>()?;
649    Ok(Some(projection))
650}
651
652fn resolve_projection_column_ids(
653    scan_config: &ParquetScanConfig,
654    metadata: &RegionMetadata,
655) -> error::Result<Option<Vec<ColumnId>>> {
656    let Some(projection_names) = &scan_config.projection_names else {
657        return Ok(None);
658    };
659
660    let available_columns = metadata
661        .column_metadatas
662        .iter()
663        .map(|column| column.column_schema.name.as_str())
664        .collect::<Vec<_>>()
665        .join(", ");
666    let projection = projection_names
667        .iter()
668        .filter_map(|name| {
669            if is_internal_column(name) {
670                return None;
671            }
672            Some(
673                metadata
674                    .column_metadatas
675                    .iter()
676                    .find(|column| column.column_schema.name == *name)
677                    .map(|column| column.column_id)
678                    .ok_or_else(|| {
679                        error::IllegalConfigSnafu {
680                            msg: format!(
681                                "Unknown column '{}' in projection_names, available columns: [{}]",
682                                name, available_columns
683                            ),
684                        }
685                        .build()
686                    }),
687            )
688        })
689        .collect::<error::Result<Vec<_>>>()?;
690    Ok(Some(projection))
691}
692
693fn resolve_row_groups(
694    scan_config: &ParquetScanConfig,
695    num_row_groups: usize,
696) -> error::Result<Vec<usize>> {
697    match &scan_config.row_groups {
698        Some(row_groups) => {
699            for row_group_idx in row_groups {
700                if *row_group_idx >= num_row_groups {
701                    return Err(error::IllegalConfigSnafu {
702                        msg: format!(
703                            "Invalid row group {} in row_groups, parquet file has row groups [0, {})",
704                            row_group_idx, num_row_groups
705                        ),
706                    }
707                    .build());
708                }
709            }
710            Ok(row_groups.clone())
711        }
712        None => Ok((0..num_row_groups).collect()),
713    }
714}
715
716/// Sums the compressed byte size of the selected row groups, used as the denominator for
717/// scan throughput so partial-row-group benchmarks aren't measured against the whole file.
718fn scanned_row_group_bytes(
719    parquet_meta: &parquet::file::metadata::ParquetMetaData,
720    row_groups: &[usize],
721) -> u64 {
722    row_groups
723        .iter()
724        .map(|&idx| parquet_meta.row_group(idx).compressed_size() as u64)
725        .sum()
726}
727
728fn projection_names_display(scan_config: &ParquetScanConfig) -> Option<String> {
729    scan_config
730        .projection_names
731        .as_ref()
732        .map(|cols| cols.join(", "))
733}
734
735fn row_groups_display(row_groups: &[usize], total_row_groups: usize) -> String {
736    if row_groups.len() == total_row_groups {
737        "all row groups".to_string()
738    } else {
739        row_groups
740            .iter()
741            .map(|idx| idx.to_string())
742            .collect::<Vec<_>>()
743            .join(", ")
744    }
745}
746
747fn override_pk_to_binary(schema: &SchemaRef) -> SchemaRef {
748    let new_fields: Vec<_> = schema
749        .fields()
750        .iter()
751        .map(|f| {
752            if f.name() == PRIMARY_KEY_COLUMN_NAME {
753                Arc::new(Field::new(
754                    PRIMARY_KEY_COLUMN_NAME,
755                    ArrowDataType::Binary,
756                    f.is_nullable(),
757                ))
758            } else {
759                f.clone()
760            }
761        })
762        .collect();
763    Arc::new(Schema::new(new_fields))
764}
765
766fn parse_batch_size(s: &str) -> Result<usize, String> {
767    let batch_size = s
768        .parse::<usize>()
769        .map_err(|e| format!("invalid batch size '{s}': {e}"))?;
770    if batch_size == 0 {
771        return Err("batch size must be greater than 0".to_string());
772    }
773    Ok(batch_size)
774}
775
776fn parse_region_id(s: &str) -> error::Result<RegionId> {
777    if s.contains(':') {
778        let parts: Vec<&str> = s.splitn(2, ':').collect();
779        let table_id: u32 = parts[0].parse().map_err(|e| {
780            error::IllegalConfigSnafu {
781                msg: format!("invalid table_id in region_id '{}': {}", s, e),
782            }
783            .build()
784        })?;
785        let region_num: u32 = parts[1].parse().map_err(|e| {
786            error::IllegalConfigSnafu {
787                msg: format!("invalid region_num in region_id '{}': {}", s, e),
788            }
789            .build()
790        })?;
791        Ok(RegionId::new(table_id, region_num))
792    } else {
793        let id: u64 = s.parse().map_err(|e| {
794            error::IllegalConfigSnafu {
795                msg: format!("invalid region_id '{}': {}", s, e),
796            }
797            .build()
798        })?;
799        Ok(RegionId::from_u64(id))
800    }
801}
802
803fn parse_path_type(s: &str) -> error::Result<PathType> {
804    match s.to_lowercase().as_str() {
805        "bare" => Ok(PathType::Bare),
806        "data" => Ok(PathType::Data),
807        "metadata" => Ok(PathType::Metadata),
808        _ => Err(error::IllegalConfigSnafu {
809            msg: format!("invalid path_type '{}', expected: bare, data, metadata", s),
810        }
811        .build()),
812    }
813}
814
815fn format_bytes(bytes: u64) -> String {
816    const KIB: u64 = 1024;
817    const MIB: u64 = 1024 * KIB;
818    const GIB: u64 = 1024 * MIB;
819    if bytes >= GIB {
820        format!("{:.2} GiB", bytes as f64 / GIB as f64)
821    } else if bytes >= MIB {
822        format!("{:.2} MiB", bytes as f64 / MIB as f64)
823    } else if bytes >= KIB {
824        format!("{:.2} KiB", bytes as f64 / KIB as f64)
825    } else {
826        format!("{} B", bytes)
827    }
828}
829
830fn format_rate(rate: f64) -> String {
831    if !rate.is_finite() {
832        return "inf rows".to_string();
833    }
834    format!("{rate:.2} rows")
835}
836
837fn format_bytes_per_sec(bytes_per_sec: f64) -> String {
838    if !bytes_per_sec.is_finite() {
839        return "inf B/s".to_string();
840    }
841    format!("{}/s", format_bytes(bytes_per_sec as u64))
842}
843
844#[cfg(test)]
845mod tests {
846    use api::v1::SemanticType;
847    use datatypes::prelude::ConcreteDataType;
848    use serde_json::json;
849    use store_api::metadata::{ColumnMetadata, RegionMetadataBuilder};
850    use store_api::storage::ColumnSchema;
851
852    use super::*;
853
854    fn new_test_metadata() -> RegionMetadata {
855        let mut builder = RegionMetadataBuilder::new(RegionId::new(1, 0));
856        builder
857            .push_column_metadata(ColumnMetadata {
858                column_schema: ColumnSchema::new(
859                    "host",
860                    ConcreteDataType::string_datatype(),
861                    false,
862                ),
863                semantic_type: SemanticType::Tag,
864                column_id: 1,
865            })
866            .push_column_metadata(ColumnMetadata {
867                column_schema: ColumnSchema::new("cpu", ConcreteDataType::float64_datatype(), true),
868                semantic_type: SemanticType::Field,
869                column_id: 2,
870            })
871            .push_column_metadata(ColumnMetadata {
872                column_schema: ColumnSchema::new(
873                    "ts",
874                    ConcreteDataType::timestamp_millisecond_datatype(),
875                    false,
876                ),
877                semantic_type: SemanticType::Timestamp,
878                column_id: 3,
879            })
880            .primary_key(vec![1]);
881        builder.build().unwrap()
882    }
883
884    #[test]
885    fn test_parse_region_id() {
886        assert_eq!(parse_region_id("1024:7").unwrap(), RegionId::new(1024, 7));
887        assert_eq!(
888            parse_region_id(&RegionId::new(1, 2).as_u64().to_string()).unwrap(),
889            RegionId::new(1, 2)
890        );
891    }
892
893    #[test]
894    fn test_parse_path_type() {
895        assert_eq!(parse_path_type("bare").unwrap(), PathType::Bare);
896        assert_eq!(parse_path_type("data").unwrap(), PathType::Data);
897        assert_eq!(parse_path_type("metadata").unwrap(), PathType::Metadata);
898    }
899
900    #[test]
901    fn test_parse_scan_config_projection_names() {
902        let config: ParquetScanConfig =
903            serde_json::from_value(json!({ "projection_names": ["host", "ts"] })).unwrap();
904        assert_eq!(
905            config.projection_names,
906            Some(vec!["host".to_string(), "ts".to_string()])
907        );
908    }
909
910    #[test]
911    fn test_parse_scan_config_row_groups() {
912        let config: ParquetScanConfig =
913            serde_json::from_value(json!({ "row_groups": [0, 2, 4] })).unwrap();
914        assert_eq!(config.row_groups, Some(vec![0, 2, 4]));
915    }
916
917    #[test]
918    fn test_resolve_projection_names() {
919        let metadata = new_test_metadata();
920        let projection = resolve_projection_names(
921            &ParquetScanConfig {
922                projection_names: Some(vec!["cpu".to_string(), "host".to_string()]),
923                row_groups: None,
924            },
925            &metadata,
926        )
927        .unwrap();
928        assert_eq!(projection, Some(vec![1, 0]));
929    }
930
931    #[test]
932    fn test_resolve_projection_column_ids() {
933        let metadata = new_test_metadata();
934        let projection = resolve_projection_column_ids(
935            &ParquetScanConfig {
936                projection_names: Some(vec!["cpu".to_string(), "host".to_string()]),
937                row_groups: None,
938            },
939            &metadata,
940        )
941        .unwrap();
942        assert_eq!(projection, Some(vec![2, 1]));
943    }
944
945    #[test]
946    fn test_resolve_projection_column_ids_ignores_internal_columns() {
947        let metadata = new_test_metadata();
948        let projection = resolve_projection_column_ids(
949            &ParquetScanConfig {
950                projection_names: Some(vec![
951                    "cpu".to_string(),
952                    "__primary_key".to_string(),
953                    "__sequence".to_string(),
954                    "__op_type".to_string(),
955                ]),
956                row_groups: None,
957            },
958            &metadata,
959        )
960        .unwrap();
961        assert_eq!(projection, Some(vec![2]));
962    }
963
964    #[test]
965    fn test_resolve_projection_names_unknown() {
966        let metadata = new_test_metadata();
967        let err = resolve_projection_names(
968            &ParquetScanConfig {
969                projection_names: Some(vec!["memory".to_string()]),
970                row_groups: None,
971            },
972            &metadata,
973        )
974        .unwrap_err();
975        let msg = err.to_string();
976        assert!(msg.contains("projection_names"));
977        assert!(msg.contains("host"));
978        assert!(msg.contains("cpu"));
979        assert!(msg.contains("ts"));
980    }
981
982    #[test]
983    fn test_resolve_row_groups_all() {
984        assert_eq!(
985            resolve_row_groups(&ParquetScanConfig::default(), 3).unwrap(),
986            vec![0, 1, 2]
987        );
988    }
989
990    #[test]
991    fn test_resolve_row_groups_subset() {
992        let config = ParquetScanConfig {
993            projection_names: None,
994            row_groups: Some(vec![2, 0]),
995        };
996        assert_eq!(resolve_row_groups(&config, 4).unwrap(), vec![2, 0]);
997    }
998
999    #[test]
1000    fn test_resolve_row_groups_invalid() {
1001        let config = ParquetScanConfig {
1002            projection_names: None,
1003            row_groups: Some(vec![3]),
1004        };
1005        let err = resolve_row_groups(&config, 3).unwrap_err();
1006        assert!(err.to_string().contains("Invalid row group 3"));
1007    }
1008
1009    #[test]
1010    fn test_sst_file_path_resolution() {
1011        let file_id = FileId::parse_str("00020380-009c-426d-953e-b4e34c15af34").unwrap();
1012        let region_file_id = RegionFileId::new(RegionId::new(1024, 0), file_id);
1013        assert_eq!(
1014            sst_file_path("data/greptime/public/1024", region_file_id, PathType::Bare),
1015            "data/greptime/public/1024/1024_0000000000/00020380-009c-426d-953e-b4e34c15af34.parquet"
1016        );
1017    }
1018}