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