Skip to main content

cmd/datanode/
scanbench.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::collections::HashMap;
16use std::fmt;
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::time::Instant;
20
21use clap::Parser;
22use colored::Colorize;
23use common_base::Plugins;
24use common_error::ext::{BoxedError, PlainError};
25use common_error::status_code::StatusCode;
26use common_meta::cache::{new_schema_cache, new_table_schema_cache};
27use common_meta::key::SchemaMetadataManager;
28use common_meta::kv_backend::memory::MemoryKvBackend;
29use common_wal::config::DatanodeWalConfig;
30use datafusion::execution::SessionStateBuilder;
31use datafusion::logical_expr::{BinaryExpr, Expr as DfExpr, ExprSchemable, Operator};
32use datafusion_common::tree_node::{Transformed, TreeNodeRewriter};
33use datafusion_common::{DFSchemaRef, ScalarValue, ToDFSchema};
34use datafusion_physical_plan::metrics::ExecutionPlanMetricsSet;
35use datafusion_physical_plan::{DisplayAs, DisplayFormatType};
36use datatypes::arrow::compute;
37use futures::StreamExt;
38use futures::stream::FuturesUnordered;
39use log_store::kafka::log_store::KafkaLogStore;
40use log_store::noop::log_store::NoopLogStore;
41use log_store::raft_engine::log_store::RaftEngineLogStore;
42use mito2::config::MitoConfig;
43use mito2::engine::MitoEngine;
44use mito2::sst::file_ref::FileReferenceManager;
45use moka::future::CacheBuilder;
46use object_store::manager::ObjectStoreManager;
47use object_store::util::normalize_dir;
48use query::optimizer::parallelize_scan::ParallelizeScan;
49use serde::Deserialize;
50use snafu::{OptionExt, ResultExt};
51use sqlparser::ast::ExprWithAlias as SqlExprWithAlias;
52use sqlparser::dialect::GenericDialect;
53use sqlparser::parser::Parser as SqlParser;
54use store_api::metadata::RegionMetadata;
55use store_api::path_utils::WAL_DIR;
56use store_api::region_engine::{PrepareRequest, QueryScanContext, RegionEngine};
57use store_api::region_request::{RegionOpenRequest, RegionRequest};
58use store_api::storage::{RegionId, ScanRequest, TimeSeriesDistribution, TimeSeriesRowSelector};
59use tokio::fs;
60
61use crate::datanode::tool_util::{
62    build_object_store, format_bytes, parse_config, parse_path_type, parse_region_id,
63};
64use crate::error;
65
66/// Displays a scanner using DataFusion's verbose explain format.
67struct VerboseScannerDisplay<'a, T: ?Sized>(&'a T);
68
69impl<T: DisplayAs + ?Sized> fmt::Display for VerboseScannerDisplay<'_, T> {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        self.0.fmt_as(DisplayFormatType::Verbose, f)
72    }
73}
74
75/// Scan benchmark command - benchmarks scanning a region directly from storage.
76#[derive(Debug, Parser)]
77pub struct ScanbenchCommand {
78    /// Path to config TOML file (same format as standalone/datanode config)
79    #[clap(long, value_name = "FILE")]
80    config: PathBuf,
81
82    /// Region ID: either numeric u64 (e.g. "4398046511104") or "table_id:region_num" (e.g. "1024:0")
83    #[clap(long)]
84    region_id: String,
85
86    /// Table directory relative to data home (e.g. "data/greptime/public/1024/")
87    #[clap(long)]
88    table_dir: String,
89
90    /// Scanner type: seq, unordered, series
91    #[clap(long, default_value = "seq")]
92    scanner: String,
93
94    /// Path to scan request JSON config file (optional)
95    #[clap(long, value_name = "FILE")]
96    scan_config: Option<PathBuf>,
97
98    /// Number of partitions for parallel scan (simulates parallelism)
99    #[clap(long, default_value = "1")]
100    parallelism: usize,
101
102    /// Number of iterations for benchmarking
103    #[clap(long, default_value = "1")]
104    iterations: usize,
105
106    /// Path type for the region: bare, data, metadata
107    #[clap(long, default_value = "bare")]
108    path_type: String,
109
110    /// Verbose output
111    #[clap(short, long, default_value_t = false)]
112    verbose: bool,
113
114    /// Output pprof flamegraph
115    #[clap(long, value_name = "FILE")]
116    pprof_file: Option<PathBuf>,
117
118    /// Enable WAL replay when opening the region.
119    #[clap(long, default_value_t = false)]
120    enable_wal: bool,
121
122    /// Start pprof after the first iteration (use first iteration as warmup).
123    #[clap(long, default_value_t = false)]
124    pprof_after_warmup: bool,
125}
126
127/// JSON config for scan request parameters.
128#[derive(Debug, Deserialize, Default)]
129struct ScanConfig {
130    projection: Option<Vec<usize>>,
131    projection_names: Option<Vec<String>>,
132    filters: Option<Vec<String>>,
133    series_row_selector: Option<String>,
134}
135
136fn resolve_projection(
137    scan_config: &ScanConfig,
138    metadata: Option<&RegionMetadata>,
139) -> error::Result<Option<Vec<usize>>> {
140    if scan_config.projection.is_some() && scan_config.projection_names.is_some() {
141        return Err(error::IllegalConfigSnafu {
142            msg: "scan config cannot contain both 'projection' and 'projection_names'".to_string(),
143        }
144        .build());
145    }
146
147    if let Some(projection) = &scan_config.projection {
148        return Ok(Some(projection.clone()));
149    }
150
151    if let Some(projection_names) = &scan_config.projection_names {
152        let metadata = metadata.context(error::IllegalConfigSnafu {
153            msg: "Missing region metadata while resolving 'projection_names'".to_string(),
154        })?;
155        let available_columns = metadata
156            .column_metadatas
157            .iter()
158            .map(|column| column.column_schema.name.as_str())
159            .collect::<Vec<_>>()
160            .join(", ");
161        let projection = projection_names
162            .iter()
163            .map(|name| {
164                metadata
165                    .column_index_by_name(name)
166                    .with_context(|| error::IllegalConfigSnafu {
167                        msg: format!(
168                            "Unknown column '{}' in projection_names, available columns: [{}]",
169                            name, available_columns
170                        ),
171                    })
172            })
173            .collect::<error::Result<Vec<_>>>()?;
174        return Ok(Some(projection));
175    }
176
177    Ok(None)
178}
179
180/// Rewrites literal values in comparison expressions to match the column's arrow type.
181struct LiteralTypeCaster {
182    schema: DFSchemaRef,
183}
184
185impl TreeNodeRewriter for LiteralTypeCaster {
186    type Node = DfExpr;
187
188    fn f_up(&mut self, expr: DfExpr) -> datafusion_common::Result<Transformed<DfExpr>> {
189        let DfExpr::BinaryExpr(BinaryExpr { left, op, right }) = &expr else {
190            return Ok(Transformed::no(expr));
191        };
192
193        if !matches!(
194            op,
195            Operator::Eq
196                | Operator::NotEq
197                | Operator::Lt
198                | Operator::LtEq
199                | Operator::Gt
200                | Operator::GtEq
201        ) {
202            return Ok(Transformed::no(expr));
203        }
204
205        let (col_expr, lit_expr, col_left) = match (left.as_ref(), right.as_ref()) {
206            (col @ DfExpr::Column(_), lit @ DfExpr::Literal(_, _)) => (col, lit, true),
207            (lit @ DfExpr::Literal(_, _), col @ DfExpr::Column(_)) => (col, lit, false),
208            _ => return Ok(Transformed::no(expr)),
209        };
210
211        let col_type = col_expr.get_type(self.schema.as_ref())?;
212        let DfExpr::Literal(scalar, _) = lit_expr else {
213            unreachable!()
214        };
215
216        if scalar.data_type() == col_type {
217            return Ok(Transformed::no(expr));
218        }
219
220        let lit_array = scalar.to_array()?;
221        let casted = compute::cast(lit_array.as_ref(), &col_type).map_err(|e| {
222            datafusion_common::DataFusionError::Internal(format!(
223                "Failed to cast literal {:?} to {:?}: {}",
224                scalar, col_type, e
225            ))
226        })?;
227        let casted_scalar = ScalarValue::try_from_array(&casted, 0)?;
228
229        let new_lit = DfExpr::Literal(casted_scalar, None);
230        let (new_left, new_right) = if col_left {
231            (left.clone(), Box::new(new_lit))
232        } else {
233            (Box::new(new_lit), right.clone())
234        };
235
236        Ok(Transformed::yes(DfExpr::BinaryExpr(BinaryExpr {
237            left: new_left,
238            op: *op,
239            right: new_right,
240        })))
241    }
242}
243
244fn convert_literal_types(
245    exprs: Vec<DfExpr>,
246    schema: &DFSchemaRef,
247) -> datafusion_common::Result<Vec<DfExpr>> {
248    use datafusion_common::tree_node::TreeNode;
249
250    let mut caster = LiteralTypeCaster {
251        schema: schema.clone(),
252    };
253    exprs
254        .into_iter()
255        .map(|e| e.rewrite(&mut caster).map(|x| x.data))
256        .collect()
257}
258
259fn resolve_filters(
260    scan_config: &ScanConfig,
261    metadata: &RegionMetadata,
262) -> error::Result<Vec<DfExpr>> {
263    let Some(filters) = &scan_config.filters else {
264        return Ok(Vec::new());
265    };
266
267    let df_schema = metadata
268        .schema
269        .arrow_schema()
270        .clone()
271        .to_dfschema()
272        .map_err(|e| {
273            error::IllegalConfigSnafu {
274                msg: format!("Failed to convert region schema to DataFusion schema: {e}"),
275            }
276            .build()
277        })?;
278
279    let state = SessionStateBuilder::new()
280        .with_config(Default::default())
281        .with_runtime_env(Default::default())
282        .with_default_features()
283        .build();
284
285    let exprs: Vec<DfExpr> = filters
286        .iter()
287        .enumerate()
288        .map(|(idx, filter)| {
289            let mut parser = SqlParser::new(&GenericDialect {})
290                .try_with_sql(filter)
291                .map_err(|e| {
292                    error::IllegalConfigSnafu {
293                        msg: format!("Invalid filter at index {idx} ('{filter}'): {e}"),
294                    }
295                    .build()
296                })?;
297
298            let sql_expr = parser.parse_expr().map_err(|e| {
299                error::IllegalConfigSnafu {
300                    msg: format!("Invalid filter at index {idx} ('{filter}'): {e}"),
301                }
302                .build()
303            })?;
304
305            state
306                .create_logical_expr_from_sql_expr(
307                    SqlExprWithAlias {
308                        expr: sql_expr,
309                        alias: None,
310                    },
311                    &df_schema,
312                )
313                .map_err(|e| {
314                    error::IllegalConfigSnafu {
315                        msg: format!(
316                            "Failed to convert filter at index {idx} ('{filter}') to logical expr: {e}"
317                        ),
318                    }
319                    .build()
320                })
321        })
322        .collect::<error::Result<Vec<_>>>()?;
323
324    let df_schema_ref = Arc::new(df_schema);
325    convert_literal_types(exprs, &df_schema_ref).map_err(|e| {
326        error::IllegalConfigSnafu {
327            msg: format!("Failed to convert filter expression types: {e}"),
328        }
329        .build()
330    })
331}
332
333fn noop_partition_expr_fetcher() -> mito2::region::opener::PartitionExprFetcherRef {
334    struct NoopPartitionExprFetcher;
335
336    #[async_trait::async_trait]
337    impl mito2::region::opener::PartitionExprFetcher for NoopPartitionExprFetcher {
338        async fn fetch_expr(&self, _region_id: RegionId) -> Option<String> {
339            None
340        }
341    }
342
343    Arc::new(NoopPartitionExprFetcher)
344}
345
346struct EngineComponents {
347    data_home: String,
348    mito_config: MitoConfig,
349    object_store_manager: Arc<ObjectStoreManager>,
350    schema_metadata_manager: Arc<SchemaMetadataManager>,
351    file_ref_manager: Arc<FileReferenceManager>,
352    partition_expr_fetcher: mito2::region::opener::PartitionExprFetcherRef,
353}
354
355impl EngineComponents {
356    async fn build<S: store_api::logstore::LogStore>(
357        self,
358        log_store: Arc<S>,
359    ) -> error::Result<MitoEngine> {
360        MitoEngine::new(
361            &self.data_home,
362            self.mito_config,
363            log_store,
364            self.object_store_manager,
365            self.schema_metadata_manager,
366            self.file_ref_manager,
367            self.partition_expr_fetcher,
368            Plugins::default(),
369        )
370        .await
371        .map_err(BoxedError::new)
372        .context(error::BuildCliSnafu)
373    }
374}
375
376fn mock_schema_metadata_manager() -> Arc<SchemaMetadataManager> {
377    let kv_backend = Arc::new(MemoryKvBackend::new());
378    let table_schema_cache = Arc::new(new_table_schema_cache(
379        "table_schema_name_cache".to_string(),
380        CacheBuilder::default().build(),
381        kv_backend.clone(),
382    ));
383    let schema_cache = Arc::new(new_schema_cache(
384        "schema_cache".to_string(),
385        CacheBuilder::default().build(),
386        kv_backend.clone(),
387    ));
388    Arc::new(SchemaMetadataManager::new(table_schema_cache, schema_cache))
389}
390
391impl ScanbenchCommand {
392    pub async fn run(&self) -> error::Result<()> {
393        if self.verbose {
394            common_telemetry::init_default_ut_logging();
395        }
396
397        println!("{}", "Starting scanbench...".cyan().bold());
398
399        let region_id = parse_region_id(&self.region_id)?;
400        let path_type = parse_path_type(&self.path_type)?;
401        println!(
402            "{} Region ID: {} (u64: {})",
403            "✓".green(),
404            self.region_id,
405            region_id.as_u64()
406        );
407
408        // Parse config and build object store
409        let (store_cfg, mito_config, wal_config) = parse_config(&self.config)?;
410        println!("{} Config parsed", "✓".green());
411
412        let object_store = build_object_store(&store_cfg).await?;
413        println!("{} Object store initialized", "✓".green());
414
415        let object_store_manager =
416            Arc::new(ObjectStoreManager::new("default", object_store.clone()));
417
418        // Create mock dependencies
419        let schema_metadata_manager = mock_schema_metadata_manager();
420        let file_ref_manager = Arc::new(FileReferenceManager::new(None));
421        let partition_expr_fetcher = noop_partition_expr_fetcher();
422
423        // Create MitoEngine with appropriate log store
424        let components = EngineComponents {
425            data_home: store_cfg.data_home.clone(),
426            mito_config,
427            object_store_manager,
428            schema_metadata_manager,
429            file_ref_manager,
430            partition_expr_fetcher,
431        };
432
433        let engine = match &wal_config {
434            DatanodeWalConfig::RaftEngine(raft_engine_config) if self.enable_wal => {
435                let data_home = normalize_dir(&store_cfg.data_home);
436                let wal_dir = match &raft_engine_config.dir {
437                    Some(dir) => dir.clone(),
438                    None => format!("{}{WAL_DIR}", data_home),
439                };
440                fs::create_dir_all(&wal_dir).await.map_err(|e| {
441                    error::IllegalConfigSnafu {
442                        msg: format!("failed to create WAL directory {}: {e}", wal_dir),
443                    }
444                    .build()
445                })?;
446                let log_store = Arc::new(
447                    RaftEngineLogStore::try_new(wal_dir, raft_engine_config)
448                        .await
449                        .map_err(BoxedError::new)
450                        .context(error::BuildCliSnafu)?,
451                );
452                println!("{} Using RaftEngine WAL", "✓".green());
453                components.build(log_store).await?
454            }
455            DatanodeWalConfig::Kafka(kafka_config) if self.enable_wal => {
456                let log_store = Arc::new(
457                    KafkaLogStore::try_new(kafka_config, None)
458                        .await
459                        .map_err(BoxedError::new)
460                        .context(error::BuildCliSnafu)?,
461                );
462                println!("{} Using Kafka WAL", "✓".green());
463                components.build(log_store).await?
464            }
465            _ => {
466                let log_store = Arc::new(NoopLogStore);
467                println!(
468                    "{} Using NoopLogStore (enable_wal={})",
469                    "✓".green(),
470                    self.enable_wal
471                );
472                components.build(log_store).await?
473            }
474        };
475
476        // Open region
477        let open_request = RegionOpenRequest {
478            engine: "mito".to_string(),
479            table_dir: self.table_dir.clone(),
480            path_type,
481            options: HashMap::default(),
482            skip_wal_replay: !self.enable_wal,
483            checkpoint: None,
484            requirements: Default::default(),
485        };
486
487        engine
488            .handle_request(region_id, RegionRequest::Open(open_request))
489            .await
490            .map_err(BoxedError::new)
491            .context(error::BuildCliSnafu)?;
492        println!("{} Region opened", "✓".green());
493
494        // Load scan config
495        let scan_config = if let Some(path) = &self.scan_config {
496            let content = tokio::fs::read_to_string(path)
497                .await
498                .context(error::FileIoSnafu)?;
499            serde_json::from_str::<ScanConfig>(&content).context(error::SerdeJsonSnafu)?
500        } else {
501            ScanConfig::default()
502        };
503        let metadata = engine
504            .get_metadata(region_id)
505            .await
506            .map_err(BoxedError::new)
507            .context(error::BuildCliSnafu)?;
508        let projection = resolve_projection(&scan_config, Some(&metadata))?;
509        let filters = resolve_filters(&scan_config, &metadata)?;
510
511        // Build scan request
512        let distribution = match self.scanner.as_str() {
513            "seq" => None,
514            "unordered" => Some(TimeSeriesDistribution::TimeWindowed),
515            "series" => Some(TimeSeriesDistribution::PerSeries),
516            other => {
517                return Err(error::IllegalConfigSnafu {
518                    msg: format!(
519                        "Unknown scanner type '{}', expected: seq, unordered, series",
520                        other
521                    ),
522                }
523                .build());
524            }
525        };
526
527        let series_row_selector = match scan_config.series_row_selector.as_deref() {
528            Some("last_row") => Some(TimeSeriesRowSelector::LastRow),
529            Some(other) => {
530                return Err(error::IllegalConfigSnafu {
531                    msg: format!("Unknown series_row_selector '{}'", other),
532                }
533                .build());
534            }
535            None => None,
536        };
537
538        println!(
539            "{} Scanner: {}, Parallelism: {}, Iterations: {}",
540            "ℹ".blue(),
541            self.scanner,
542            self.parallelism,
543            self.iterations,
544        );
545
546        // Start profiling if pprof_file is specified (unless pprof_after_warmup is set)
547        #[cfg(unix)]
548        let mut profiler_guard = if self.pprof_file.is_some() && !self.pprof_after_warmup {
549            println!("{} Starting profiling...", "⚡".yellow());
550            Some(
551                pprof::ProfilerGuardBuilder::default()
552                    .frequency(99)
553                    .blocklist(&["libc", "libgcc", "pthread", "vdso"])
554                    .build()
555                    .map_err(|e| {
556                        BoxedError::new(PlainError::new(
557                            format!("Failed to start profiler: {e}"),
558                            StatusCode::Unexpected,
559                        ))
560                    })
561                    .context(error::BuildCliSnafu)?,
562            )
563        } else {
564            None
565        };
566
567        #[cfg(not(unix))]
568        if self.pprof_file.is_some() {
569            eprintln!(
570                "{}: Profiling is not supported on this platform",
571                "Warning".yellow()
572            );
573        }
574
575        let mut total_rows_all = 0u64;
576        let mut total_elapsed_all = std::time::Duration::ZERO;
577
578        for iteration in 0..self.iterations {
579            let request = ScanRequest {
580                projection: projection.clone(),
581                filters: filters.clone(),
582                series_row_selector,
583                distribution,
584                ..Default::default()
585            };
586
587            let start = Instant::now();
588
589            // Get scanner
590            let mut scanner = engine
591                .handle_query(region_id, request)
592                .await
593                .map_err(BoxedError::new)
594                .context(error::BuildCliSnafu)?;
595
596            // Get partition ranges and apply parallelism
597            let original_partitions = scanner.properties().partitions.clone();
598            let total_ranges: usize = original_partitions.iter().map(|p| p.len()).sum();
599
600            if self.verbose {
601                println!(
602                    "  {} Original partitions: {}, total ranges: {}",
603                    "ℹ".blue(),
604                    original_partitions.len(),
605                    total_ranges
606                );
607            }
608
609            if self.parallelism > 1 {
610                // Flatten all ranges
611                let all_ranges: Vec<_> = original_partitions.into_iter().flatten().collect();
612
613                // Distribute ranges across partitions
614                let mut partitions =
615                    ParallelizeScan::assign_partition_range(all_ranges, self.parallelism);
616
617                // Sort ranges within each partition by start time ascending
618                for partition in &mut partitions {
619                    partition.sort_by_key(|a| a.start);
620                }
621
622                scanner
623                    .prepare(
624                        PrepareRequest::default()
625                            .with_ranges(partitions)
626                            .with_target_partitions(self.parallelism),
627                    )
628                    .map_err(BoxedError::new)
629                    .context(error::BuildCliSnafu)?;
630            }
631
632            // Scan all partitions
633            let num_partitions = scanner.properties().partitions.len();
634            let ctx = QueryScanContext {
635                explain_verbose: self.verbose,
636            };
637            let metrics_set = ExecutionPlanMetricsSet::new();
638
639            let mut scan_futures = FuturesUnordered::new();
640
641            for partition_idx in 0..num_partitions {
642                let mut stream = scanner
643                    .scan_partition(&ctx, &metrics_set, partition_idx)
644                    .map_err(BoxedError::new)
645                    .context(error::BuildCliSnafu)?;
646
647                scan_futures.push(tokio::spawn(async move {
648                    let mut rows = 0u64;
649                    let mut array_mem_size = 0u64;
650                    let mut estimated_size = 0u64;
651                    while let Some(batch_result) = stream.next().await {
652                        match batch_result {
653                            Ok(batch) => {
654                                rows += batch.num_rows() as u64;
655                                let df_batch = batch.df_record_batch();
656                                array_mem_size += df_batch.get_array_memory_size() as u64;
657                                estimated_size +=
658                                    mito2::memtable::record_batch_estimated_size(df_batch) as u64;
659                            }
660                            Err(e) => {
661                                return Err(BoxedError::new(e));
662                            }
663                        }
664                    }
665                    Ok::<(u64, u64, u64), BoxedError>((rows, array_mem_size, estimated_size))
666                }));
667            }
668
669            let mut total_rows = 0u64;
670            let mut total_array_mem_size = 0u64;
671            let mut total_estimated_size = 0u64;
672            while let Some(task) = scan_futures.next().await {
673                let result = task
674                    .map_err(|e| {
675                        BoxedError::new(PlainError::new(
676                            format!("scan task failed: {e}"),
677                            StatusCode::Unexpected,
678                        ))
679                    })
680                    .context(error::BuildCliSnafu)?;
681                let (rows, array_mem_size, estimated_size) =
682                    result.context(error::BuildCliSnafu)?;
683                total_rows += rows;
684                total_array_mem_size += array_mem_size;
685                total_estimated_size += estimated_size;
686            }
687
688            let elapsed = start.elapsed();
689            total_rows_all += total_rows;
690            total_elapsed_all += elapsed;
691
692            println!(
693                "  [iter {}] {} rows in {:?} ({} partitions), array_mem_size: {}, estimated_size: {}",
694                iteration + 1,
695                total_rows.to_string().cyan(),
696                elapsed,
697                num_partitions,
698                format_bytes(total_array_mem_size),
699                format_bytes(total_estimated_size),
700            );
701
702            if self.verbose {
703                println!(
704                    "  {} Scanner explain: {}",
705                    "ℹ".blue(),
706                    VerboseScannerDisplay(scanner.as_ref())
707                );
708            }
709
710            // Start profiling after the first iteration (warmup) if pprof_after_warmup is set
711            #[cfg(unix)]
712            if iteration == 0
713                && self.pprof_after_warmup
714                && self.pprof_file.is_some()
715                && profiler_guard.is_none()
716            {
717                println!(
718                    "{} Starting profiling after warmup iteration...",
719                    "⚡".yellow()
720                );
721                profiler_guard = Some(
722                    pprof::ProfilerGuardBuilder::default()
723                        .frequency(99)
724                        .blocklist(&["libc", "libgcc", "pthread", "vdso"])
725                        .build()
726                        .map_err(|e| {
727                            BoxedError::new(PlainError::new(
728                                format!("Failed to start profiler: {e}"),
729                                StatusCode::Unexpected,
730                            ))
731                        })
732                        .context(error::BuildCliSnafu)?,
733                );
734            }
735        }
736
737        // Stop profiling and generate flamegraph if enabled
738        #[cfg(unix)]
739        if let (Some(guard), Some(pprof_file)) = (profiler_guard, &self.pprof_file) {
740            println!("{} Generating flamegraph...", "🔥".yellow());
741            match guard.report().build() {
742                Ok(report) => {
743                    let mut flamegraph_data = Vec::new();
744                    if let Err(e) = report.flamegraph(&mut flamegraph_data) {
745                        println!("{}: Failed to generate flamegraph: {}", "Error".red(), e);
746                    } else if let Err(e) = std::fs::write(pprof_file, flamegraph_data) {
747                        println!(
748                            "{}: Failed to write flamegraph to {}: {}",
749                            "Error".red(),
750                            pprof_file.display(),
751                            e
752                        );
753                    } else {
754                        println!(
755                            "{} Flamegraph saved to {}",
756                            "✓".green(),
757                            pprof_file.display().to_string().cyan()
758                        );
759                    }
760                }
761                Err(e) => {
762                    println!("{}: Failed to generate pprof report: {}", "Error".red(), e);
763                }
764            }
765        }
766
767        // Summary
768        if self.iterations > 1 {
769            let avg_elapsed = total_elapsed_all / self.iterations as u32;
770            let avg_rows = total_rows_all / self.iterations as u64;
771            println!(
772                "\n{} Average: {} rows in {:?} over {} iterations",
773                "Summary".green().bold(),
774                avg_rows.to_string().cyan(),
775                avg_elapsed,
776                self.iterations,
777            );
778        }
779
780        println!("\n{}", "Benchmark completed!".green().bold());
781        Ok(())
782    }
783}
784
785#[cfg(test)]
786mod tests {
787    use datatypes::prelude::ConcreteDataType;
788    use datatypes::schema::ColumnSchema;
789    use sqlparser::ast::{BinaryOperator, Expr};
790    use sqlparser::dialect::GenericDialect;
791    use sqlparser::parser::Parser;
792    use store_api::metadata::{ColumnMetadata, RegionMetadataBuilder};
793    use store_api::storage::RegionId;
794
795    use super::{ScanConfig, resolve_filters, resolve_projection};
796    use crate::error;
797
798    #[test]
799    fn test_parse_scan_config_projection_names() {
800        let json = r#"{"projection_names":["host","ts"]}"#;
801        let config: ScanConfig = serde_json::from_str(json).unwrap();
802
803        assert_eq!(
804            config.projection_names,
805            Some(vec!["host".to_string(), "ts".to_string()])
806        );
807        assert_eq!(config.projection, None);
808    }
809
810    #[test]
811    fn test_resolve_projection_by_indexes() -> error::Result<()> {
812        let config = ScanConfig {
813            projection: Some(vec![0, 2]),
814            projection_names: None,
815            filters: None,
816            series_row_selector: None,
817        };
818
819        let projection = resolve_projection(&config, None)?;
820        assert_eq!(projection, Some(vec![0, 2]));
821        Ok(())
822    }
823
824    #[test]
825    fn test_resolve_projection_by_names_without_metadata() {
826        let config = ScanConfig {
827            projection: None,
828            projection_names: Some(vec!["cpu".to_string(), "host".to_string()]),
829            filters: None,
830            series_row_selector: None,
831        };
832
833        let err = resolve_projection(&config, None).unwrap_err();
834        assert!(
835            err.to_string()
836                .contains("Missing region metadata while resolving 'projection_names'")
837        );
838    }
839
840    #[test]
841    fn test_resolve_projection_conflict_fields() {
842        let config = ScanConfig {
843            projection: Some(vec![0]),
844            projection_names: Some(vec!["host".to_string()]),
845            filters: None,
846            series_row_selector: None,
847        };
848
849        let err = resolve_projection(&config, None).unwrap_err();
850        let msg = err.to_string();
851        assert!(msg.contains("projection"));
852        assert!(msg.contains("projection_names"));
853    }
854
855    #[test]
856    fn test_sqlparser_parse_expr_string() {
857        let dialect = GenericDialect {};
858        let mut parser = Parser::new(&dialect)
859            .try_with_sql("host = 'web-1' AND cpu > 80")
860            .unwrap();
861
862        let expr = parser.parse_expr().unwrap();
863
864        match expr {
865            Expr::BinaryOp { op, .. } => assert_eq!(op, BinaryOperator::And),
866            other => panic!("expected BinaryOp, got: {other:?}"),
867        }
868    }
869
870    #[test]
871    fn test_resolve_filters_uint32_type_conversion() {
872        use api::v1::SemanticType;
873
874        let mut builder = RegionMetadataBuilder::new(RegionId::new(1, 0));
875        builder
876            .push_column_metadata(ColumnMetadata {
877                column_schema: ColumnSchema::new(
878                    "table_id",
879                    ConcreteDataType::uint32_datatype(),
880                    false,
881                ),
882                semantic_type: SemanticType::Tag,
883                column_id: 1,
884            })
885            .push_column_metadata(ColumnMetadata {
886                column_schema: ColumnSchema::new(
887                    "ts",
888                    ConcreteDataType::timestamp_millisecond_datatype(),
889                    false,
890                ),
891                semantic_type: SemanticType::Timestamp,
892                column_id: 2,
893            })
894            .primary_key(vec![1]);
895        let metadata = builder.build().unwrap();
896
897        let config = ScanConfig {
898            projection: None,
899            projection_names: None,
900            filters: Some(vec!["table_id = 1117".to_string()]),
901            series_row_selector: None,
902        };
903
904        let exprs = resolve_filters(&config, &metadata).unwrap();
905        assert_eq!(exprs.len(), 1);
906        // The expression should contain a UInt32 literal after type conversion.
907        let expr_str = format!("{}", exprs[0]);
908        assert!(
909            expr_str.contains("UInt32(1117)"),
910            "Expected UInt32(1117) in expression, got: {expr_str}"
911        );
912    }
913
914    #[test]
915    fn test_parse_scan_config_filters() {
916        let json = r#"{"filters":["host = 'web-1'","cpu > 80"]}"#;
917        let config: ScanConfig = serde_json::from_str(json).unwrap();
918
919        assert_eq!(
920            config.filters,
921            Some(vec!["host = 'web-1'".to_string(), "cpu > 80".to_string()])
922        );
923    }
924}