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, HashSet};
16use std::fmt;
17use std::path::PathBuf;
18use std::sync::Arc;
19use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
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, Serialize};
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
75struct PartitionScanStats {
76    partition: usize,
77    rows: u64,
78    batches: u64,
79    array_mem_size: u64,
80    estimated_size: u64,
81    first_batch_elapsed: Option<Duration>,
82    elapsed: Duration,
83}
84
85/// Scan benchmark command - benchmarks scanning a region directly from storage.
86#[derive(Debug, Parser)]
87pub struct ScanbenchCommand {
88    /// Path to config TOML file (same format as standalone/datanode config)
89    #[clap(long, value_name = "FILE")]
90    config: PathBuf,
91
92    /// Region ID: either numeric u64 (e.g. "4398046511104") or "table_id:region_num" (e.g. "1024:0")
93    #[clap(long)]
94    region_id: String,
95
96    /// Table directory relative to data home (e.g. "data/greptime/public/1024/")
97    #[clap(long)]
98    table_dir: String,
99
100    /// Scanner type: seq, unordered, series
101    #[clap(long, default_value = "seq")]
102    scanner: String,
103
104    /// Path to scan request JSON config file (optional)
105    #[clap(long, value_name = "FILE", conflicts_with = "scan_configs")]
106    scan_config: Option<PathBuf>,
107
108    /// Path to a JSON array of scan request configs, each executed once
109    #[clap(long, value_name = "FILE", conflicts_with = "scan_config")]
110    scan_configs: Option<PathBuf>,
111
112    /// Number of partitions for parallel scan (simulates parallelism)
113    #[clap(long, default_value = "1")]
114    parallelism: usize,
115
116    /// Number of iterations for benchmarking
117    #[clap(long, default_value = "1")]
118    iterations: usize,
119
120    /// Path type for the region: bare, data, metadata
121    #[clap(long, default_value = "bare")]
122    path_type: String,
123
124    /// Verbose output
125    #[clap(short, long, default_value_t = false)]
126    verbose: bool,
127
128    /// Output pprof flamegraph
129    #[clap(long, value_name = "FILE")]
130    pprof_file: Option<PathBuf>,
131
132    /// Output structured benchmark and analyze results as JSON
133    #[clap(long, value_name = "FILE")]
134    result_file: Option<PathBuf>,
135
136    /// Enable WAL replay when opening the region.
137    #[clap(long, default_value_t = false)]
138    enable_wal: bool,
139
140    /// Start pprof after the first iteration (use first iteration as warmup).
141    #[clap(long, default_value_t = false)]
142    pprof_after_warmup: bool,
143}
144
145/// JSON config for scan request parameters.
146#[derive(Debug, Clone, Deserialize, Default)]
147struct ScanConfig {
148    name: Option<String>,
149    projection: Option<Vec<usize>>,
150    projection_names: Option<Vec<String>>,
151    filters: Option<Vec<String>>,
152    series_row_selector: Option<String>,
153}
154
155struct ScanConfigSet {
156    configs: Vec<ScanConfig>,
157    is_suite: bool,
158}
159
160impl ScanConfigSet {
161    fn run_count(&self, iterations: usize) -> usize {
162        if self.is_suite {
163            self.configs.len()
164        } else {
165            iterations
166        }
167    }
168
169    fn query_index(&self, iteration: usize) -> usize {
170        if self.is_suite { iteration } else { 0 }
171    }
172}
173
174struct ResolvedScanConfig {
175    name: String,
176    projection: Option<Vec<usize>>,
177    filters: Vec<DfExpr>,
178    series_row_selector: Option<TimeSeriesRowSelector>,
179}
180
181struct QueryRunSummary {
182    name: String,
183    runs: u64,
184    total_rows: u64,
185    total_elapsed: Duration,
186}
187
188impl QueryRunSummary {
189    fn new(name: String) -> Self {
190        Self {
191            name,
192            runs: 0,
193            total_rows: 0,
194            total_elapsed: Duration::ZERO,
195        }
196    }
197
198    fn record(&mut self, rows: u64, elapsed: Duration) {
199        self.runs += 1;
200        self.total_rows += rows;
201        self.total_elapsed += elapsed;
202    }
203
204    fn mean_rows(&self) -> u64 {
205        self.total_rows.checked_div(self.runs).unwrap_or_default()
206    }
207
208    fn mean_elapsed(&self) -> Duration {
209        self.total_elapsed
210            .checked_div(self.runs as u32)
211            .unwrap_or_default()
212    }
213}
214
215const RESULT_FORMAT_VERSION: u32 = 1;
216
217#[derive(Debug, Serialize)]
218struct ScanbenchResult {
219    format_version: u32,
220    started_at_unix_ms: u64,
221    benchmark: BenchmarkMetadata,
222    runs: Vec<ScanRunResult>,
223    summary: BenchmarkResultSummary,
224}
225
226#[derive(Debug, Serialize)]
227struct BenchmarkMetadata {
228    scanner: String,
229    region_id: String,
230    region_id_u64: u64,
231    table_dir: String,
232    path_type: String,
233    parallelism: usize,
234    enable_wal: bool,
235    config_mode: String,
236    run_count: usize,
237}
238
239#[derive(Debug, Serialize)]
240struct NormalizedScanConfig {
241    name: String,
242    projection: Option<Vec<usize>>,
243    filters: Vec<String>,
244    series_row_selector: Option<String>,
245}
246
247#[derive(Debug, Serialize)]
248struct ScanRunResult {
249    iteration: usize,
250    query_index: usize,
251    name: String,
252    config: NormalizedScanConfig,
253    rows: u64,
254    batches: u64,
255    setup_elapsed_ns: u64,
256    scan_elapsed_ns: u64,
257    elapsed_ns: u64,
258    array_mem_size_bytes: u64,
259    estimated_size_bytes: u64,
260    partitions: Vec<PartitionResult>,
261    scanner_explain: String,
262}
263
264#[derive(Debug, Serialize)]
265struct PartitionResult {
266    partition: usize,
267    rows: u64,
268    batches: u64,
269    array_mem_size_bytes: u64,
270    estimated_size_bytes: u64,
271    first_batch_elapsed_ns: Option<u64>,
272    elapsed_ns: u64,
273}
274
275#[derive(Debug, Serialize)]
276struct BenchmarkResultSummary {
277    runs: u64,
278    total_rows: u64,
279    total_elapsed_ns: u64,
280    mean_rows: u64,
281    mean_elapsed_ns: u64,
282    queries: Vec<QueryResultSummary>,
283}
284
285#[derive(Debug, Serialize)]
286struct QueryResultSummary {
287    query_index: usize,
288    name: String,
289    runs: u64,
290    total_rows: u64,
291    total_elapsed_ns: u64,
292    mean_rows: u64,
293    mean_elapsed_ns: u64,
294}
295
296fn duration_ns(duration: Duration) -> u64 {
297    duration.as_nanos().try_into().unwrap_or(u64::MAX)
298}
299
300fn started_at_unix_ms() -> u64 {
301    SystemTime::now()
302        .duration_since(UNIX_EPOCH)
303        .unwrap_or_default()
304        .as_millis()
305        .try_into()
306        .unwrap_or(u64::MAX)
307}
308
309fn result_summary(
310    total_rows: u64,
311    total_elapsed: Duration,
312    summaries: &[QueryRunSummary],
313) -> BenchmarkResultSummary {
314    let runs = summaries.iter().map(|summary| summary.runs).sum::<u64>();
315    BenchmarkResultSummary {
316        runs,
317        total_rows,
318        total_elapsed_ns: duration_ns(total_elapsed),
319        mean_rows: total_rows.checked_div(runs).unwrap_or_default(),
320        mean_elapsed_ns: duration_ns(total_elapsed.checked_div(runs as u32).unwrap_or_default()),
321        queries: summaries
322            .iter()
323            .enumerate()
324            .map(|(index, summary)| QueryResultSummary {
325                query_index: index + 1,
326                name: summary.name.clone(),
327                runs: summary.runs,
328                total_rows: summary.total_rows,
329                total_elapsed_ns: duration_ns(summary.total_elapsed),
330                mean_rows: summary.mean_rows(),
331                mean_elapsed_ns: duration_ns(summary.mean_elapsed()),
332            })
333            .collect(),
334    }
335}
336
337async fn write_result_file(path: &PathBuf, result: &ScanbenchResult) -> error::Result<()> {
338    let content = serde_json::to_vec_pretty(result).context(error::SerdeJsonSnafu)?;
339    fs::write(path, content).await.context(error::FileIoSnafu)
340}
341
342fn validate_scan_config_suite(
343    mut configs: Vec<ScanConfig>,
344    iterations: usize,
345) -> error::Result<Vec<ScanConfig>> {
346    if iterations != 1 {
347        return Err(error::IllegalConfigSnafu {
348            msg: format!(
349                "--iterations cannot be used with --scan-configs (got {iterations}); the array length defines the run count"
350            ),
351        }
352        .build());
353    }
354    if configs.is_empty() {
355        return Err(error::IllegalConfigSnafu {
356            msg: "--scan-configs file must contain at least one scan config".to_string(),
357        }
358        .build());
359    }
360
361    let mut names = HashSet::with_capacity(configs.len());
362    for (index, config) in configs.iter_mut().enumerate() {
363        let name = match config.name.take() {
364            Some(name) => {
365                let name = name.trim();
366                if name.is_empty() {
367                    return Err(error::IllegalConfigSnafu {
368                        msg: format!("scan config at index {index} has an empty name"),
369                    }
370                    .build());
371                }
372                name.to_string()
373            }
374            None => format!("query-{:03}", index + 1),
375        };
376        if !names.insert(name.clone()) {
377            return Err(error::IllegalConfigSnafu {
378                msg: format!("duplicate scan config name '{name}' at index {index}"),
379            }
380            .build());
381        }
382        config.name = Some(name);
383    }
384
385    Ok(configs)
386}
387
388fn resolve_series_row_selector(
389    scan_config: &ScanConfig,
390) -> error::Result<Option<TimeSeriesRowSelector>> {
391    match scan_config.series_row_selector.as_deref() {
392        Some("last_row") => Ok(Some(TimeSeriesRowSelector::LastRow { after_merge: false })),
393        Some(other) => Err(error::IllegalConfigSnafu {
394            msg: format!("Unknown series_row_selector '{other}'"),
395        }
396        .build()),
397        None => Ok(None),
398    }
399}
400
401fn resolve_scan_configs(
402    config_set: &ScanConfigSet,
403    metadata: &RegionMetadata,
404) -> error::Result<Vec<ResolvedScanConfig>> {
405    config_set
406        .configs
407        .iter()
408        .enumerate()
409        .map(|(index, config)| {
410            let name = config
411                .name
412                .clone()
413                .unwrap_or_else(|| format!("query-{:03}", index + 1));
414            let projection = resolve_projection(config, Some(metadata)).map_err(|err| {
415                error::IllegalConfigSnafu {
416                    msg: format!("invalid scan config at index {index} ('{name}'): {err}"),
417                }
418                .build()
419            })?;
420            let filters = resolve_filters(config, metadata).map_err(|err| {
421                error::IllegalConfigSnafu {
422                    msg: format!("invalid scan config at index {index} ('{name}'): {err}"),
423                }
424                .build()
425            })?;
426            let series_row_selector = resolve_series_row_selector(config).map_err(|err| {
427                error::IllegalConfigSnafu {
428                    msg: format!("invalid scan config at index {index} ('{name}'): {err}"),
429                }
430                .build()
431            })?;
432            Ok(ResolvedScanConfig {
433                name,
434                projection,
435                filters,
436                series_row_selector,
437            })
438        })
439        .collect()
440}
441
442fn resolve_projection(
443    scan_config: &ScanConfig,
444    metadata: Option<&RegionMetadata>,
445) -> error::Result<Option<Vec<usize>>> {
446    if scan_config.projection.is_some() && scan_config.projection_names.is_some() {
447        return Err(error::IllegalConfigSnafu {
448            msg: "scan config cannot contain both 'projection' and 'projection_names'".to_string(),
449        }
450        .build());
451    }
452
453    if let Some(projection) = &scan_config.projection {
454        return Ok(Some(projection.clone()));
455    }
456
457    if let Some(projection_names) = &scan_config.projection_names {
458        let metadata = metadata.context(error::IllegalConfigSnafu {
459            msg: "Missing region metadata while resolving 'projection_names'".to_string(),
460        })?;
461        let available_columns = metadata
462            .column_metadatas
463            .iter()
464            .map(|column| column.column_schema.name.as_str())
465            .collect::<Vec<_>>()
466            .join(", ");
467        let projection = projection_names
468            .iter()
469            .map(|name| {
470                metadata
471                    .column_index_by_name(name)
472                    .with_context(|| error::IllegalConfigSnafu {
473                        msg: format!(
474                            "Unknown column '{}' in projection_names, available columns: [{}]",
475                            name, available_columns
476                        ),
477                    })
478            })
479            .collect::<error::Result<Vec<_>>>()?;
480        return Ok(Some(projection));
481    }
482
483    Ok(None)
484}
485
486/// Rewrites literal values in comparison expressions to match the column's arrow type.
487struct LiteralTypeCaster {
488    schema: DFSchemaRef,
489}
490
491impl TreeNodeRewriter for LiteralTypeCaster {
492    type Node = DfExpr;
493
494    fn f_up(&mut self, expr: DfExpr) -> datafusion_common::Result<Transformed<DfExpr>> {
495        let DfExpr::BinaryExpr(BinaryExpr { left, op, right }) = &expr else {
496            return Ok(Transformed::no(expr));
497        };
498
499        if !matches!(
500            op,
501            Operator::Eq
502                | Operator::NotEq
503                | Operator::Lt
504                | Operator::LtEq
505                | Operator::Gt
506                | Operator::GtEq
507        ) {
508            return Ok(Transformed::no(expr));
509        }
510
511        let (col_expr, lit_expr, col_left) = match (left.as_ref(), right.as_ref()) {
512            (col @ DfExpr::Column(_), lit @ DfExpr::Literal(_, _)) => (col, lit, true),
513            (lit @ DfExpr::Literal(_, _), col @ DfExpr::Column(_)) => (col, lit, false),
514            _ => return Ok(Transformed::no(expr)),
515        };
516
517        let col_type = col_expr.get_type(self.schema.as_ref())?;
518        let DfExpr::Literal(scalar, _) = lit_expr else {
519            unreachable!()
520        };
521
522        if scalar.data_type() == col_type {
523            return Ok(Transformed::no(expr));
524        }
525
526        let lit_array = scalar.to_array()?;
527        let casted = compute::cast(lit_array.as_ref(), &col_type).map_err(|e| {
528            datafusion_common::DataFusionError::Internal(format!(
529                "Failed to cast literal {:?} to {:?}: {}",
530                scalar, col_type, e
531            ))
532        })?;
533        let casted_scalar = ScalarValue::try_from_array(&casted, 0)?;
534
535        let new_lit = DfExpr::Literal(casted_scalar, None);
536        let (new_left, new_right) = if col_left {
537            (left.clone(), Box::new(new_lit))
538        } else {
539            (Box::new(new_lit), right.clone())
540        };
541
542        Ok(Transformed::yes(DfExpr::BinaryExpr(BinaryExpr {
543            left: new_left,
544            op: *op,
545            right: new_right,
546        })))
547    }
548}
549
550fn convert_literal_types(
551    exprs: Vec<DfExpr>,
552    schema: &DFSchemaRef,
553) -> datafusion_common::Result<Vec<DfExpr>> {
554    use datafusion_common::tree_node::TreeNode;
555
556    let mut caster = LiteralTypeCaster {
557        schema: schema.clone(),
558    };
559    exprs
560        .into_iter()
561        .map(|e| e.rewrite(&mut caster).map(|x| x.data))
562        .collect()
563}
564
565fn resolve_filters(
566    scan_config: &ScanConfig,
567    metadata: &RegionMetadata,
568) -> error::Result<Vec<DfExpr>> {
569    let Some(filters) = &scan_config.filters else {
570        return Ok(Vec::new());
571    };
572
573    let df_schema = metadata
574        .schema
575        .arrow_schema()
576        .clone()
577        .to_dfschema()
578        .map_err(|e| {
579            error::IllegalConfigSnafu {
580                msg: format!("Failed to convert region schema to DataFusion schema: {e}"),
581            }
582            .build()
583        })?;
584
585    let state = SessionStateBuilder::new()
586        .with_config(Default::default())
587        .with_runtime_env(Default::default())
588        .with_default_features()
589        .build();
590
591    let exprs: Vec<DfExpr> = filters
592        .iter()
593        .enumerate()
594        .map(|(idx, filter)| {
595            let mut parser = SqlParser::new(&GenericDialect {})
596                .try_with_sql(filter)
597                .map_err(|e| {
598                    error::IllegalConfigSnafu {
599                        msg: format!("Invalid filter at index {idx} ('{filter}'): {e}"),
600                    }
601                    .build()
602                })?;
603
604            let sql_expr = parser.parse_expr().map_err(|e| {
605                error::IllegalConfigSnafu {
606                    msg: format!("Invalid filter at index {idx} ('{filter}'): {e}"),
607                }
608                .build()
609            })?;
610
611            state
612                .create_logical_expr_from_sql_expr(
613                    SqlExprWithAlias {
614                        expr: sql_expr,
615                        alias: None,
616                    },
617                    &df_schema,
618                )
619                .map_err(|e| {
620                    error::IllegalConfigSnafu {
621                        msg: format!(
622                            "Failed to convert filter at index {idx} ('{filter}') to logical expr: {e}"
623                        ),
624                    }
625                    .build()
626                })
627        })
628        .collect::<error::Result<Vec<_>>>()?;
629
630    let df_schema_ref = Arc::new(df_schema);
631    convert_literal_types(exprs, &df_schema_ref).map_err(|e| {
632        error::IllegalConfigSnafu {
633            msg: format!("Failed to convert filter expression types: {e}"),
634        }
635        .build()
636    })
637}
638
639fn noop_partition_expr_fetcher() -> mito2::region::opener::PartitionExprFetcherRef {
640    struct NoopPartitionExprFetcher;
641
642    #[async_trait::async_trait]
643    impl mito2::region::opener::PartitionExprFetcher for NoopPartitionExprFetcher {
644        async fn fetch_expr(&self, _region_id: RegionId) -> Option<String> {
645            None
646        }
647    }
648
649    Arc::new(NoopPartitionExprFetcher)
650}
651
652struct EngineComponents {
653    data_home: String,
654    mito_config: MitoConfig,
655    object_store_manager: Arc<ObjectStoreManager>,
656    schema_metadata_manager: Arc<SchemaMetadataManager>,
657    file_ref_manager: Arc<FileReferenceManager>,
658    partition_expr_fetcher: mito2::region::opener::PartitionExprFetcherRef,
659}
660
661impl EngineComponents {
662    async fn build<S: store_api::logstore::LogStore>(
663        self,
664        log_store: Arc<S>,
665    ) -> error::Result<MitoEngine> {
666        MitoEngine::new(
667            &self.data_home,
668            self.mito_config,
669            log_store,
670            self.object_store_manager,
671            self.schema_metadata_manager,
672            self.file_ref_manager,
673            self.partition_expr_fetcher,
674            Plugins::default(),
675        )
676        .await
677        .map_err(BoxedError::new)
678        .context(error::BuildCliSnafu)
679    }
680}
681
682fn mock_schema_metadata_manager() -> Arc<SchemaMetadataManager> {
683    let kv_backend = Arc::new(MemoryKvBackend::new());
684    let table_schema_cache = Arc::new(new_table_schema_cache(
685        "table_schema_name_cache".to_string(),
686        CacheBuilder::default().build(),
687        kv_backend.clone(),
688    ));
689    let schema_cache = Arc::new(new_schema_cache(
690        "schema_cache".to_string(),
691        CacheBuilder::default().build(),
692        kv_backend.clone(),
693    ));
694    Arc::new(SchemaMetadataManager::new(table_schema_cache, schema_cache))
695}
696
697impl ScanbenchCommand {
698    async fn load_scan_config_set(&self) -> error::Result<ScanConfigSet> {
699        match (&self.scan_config, &self.scan_configs) {
700            (Some(_), Some(_)) => Err(error::IllegalConfigSnafu {
701                msg: "--scan-config and --scan-configs are mutually exclusive".to_string(),
702            }
703            .build()),
704            (Some(path), None) => {
705                let content = tokio::fs::read_to_string(path)
706                    .await
707                    .context(error::FileIoSnafu)?;
708                let config =
709                    serde_json::from_str::<ScanConfig>(&content).context(error::SerdeJsonSnafu)?;
710                Ok(ScanConfigSet {
711                    configs: vec![config],
712                    is_suite: false,
713                })
714            }
715            (None, Some(path)) => {
716                if self.iterations != 1 {
717                    return Err(error::IllegalConfigSnafu {
718                        msg: format!(
719                            "--iterations cannot be used with --scan-configs (got {}); the array length defines the run count",
720                            self.iterations
721                        ),
722                    }
723                    .build());
724                }
725                let content = tokio::fs::read_to_string(path)
726                    .await
727                    .context(error::FileIoSnafu)?;
728                let configs = serde_json::from_str::<Vec<ScanConfig>>(&content)
729                    .context(error::SerdeJsonSnafu)?;
730                Ok(ScanConfigSet {
731                    configs: validate_scan_config_suite(configs, self.iterations)?,
732                    is_suite: true,
733                })
734            }
735            (None, None) => Ok(ScanConfigSet {
736                configs: vec![ScanConfig::default()],
737                is_suite: false,
738            }),
739        }
740    }
741
742    pub async fn run(&self) -> error::Result<()> {
743        if self.verbose {
744            common_telemetry::init_default_ut_logging();
745        }
746
747        println!("{}", "Starting scanbench...".cyan().bold());
748        let benchmark_started_at_unix_ms = started_at_unix_ms();
749
750        let scan_config_set = self.load_scan_config_set().await?;
751
752        let region_id = parse_region_id(&self.region_id)?;
753        let path_type = parse_path_type(&self.path_type)?;
754        println!(
755            "{} Region ID: {} (u64: {})",
756            "✓".green(),
757            self.region_id,
758            region_id.as_u64()
759        );
760
761        // Parse config and build object store
762        let (store_cfg, mito_config, wal_config) = parse_config(&self.config)?;
763        println!("{} Config parsed", "✓".green());
764
765        let object_store = build_object_store(&store_cfg).await?;
766        println!("{} Object store initialized", "✓".green());
767
768        let object_store_manager =
769            Arc::new(ObjectStoreManager::new("default", object_store.clone()));
770
771        // Create mock dependencies
772        let schema_metadata_manager = mock_schema_metadata_manager();
773        let file_ref_manager = Arc::new(FileReferenceManager::new(None));
774        let partition_expr_fetcher = noop_partition_expr_fetcher();
775
776        // Create MitoEngine with appropriate log store
777        let components = EngineComponents {
778            data_home: store_cfg.data_home.clone(),
779            mito_config,
780            object_store_manager,
781            schema_metadata_manager,
782            file_ref_manager,
783            partition_expr_fetcher,
784        };
785
786        let engine = match &wal_config {
787            DatanodeWalConfig::RaftEngine(raft_engine_config) if self.enable_wal => {
788                let data_home = normalize_dir(&store_cfg.data_home);
789                let wal_dir = match &raft_engine_config.dir {
790                    Some(dir) => dir.clone(),
791                    None => format!("{}{WAL_DIR}", data_home),
792                };
793                fs::create_dir_all(&wal_dir).await.map_err(|e| {
794                    error::IllegalConfigSnafu {
795                        msg: format!("failed to create WAL directory {}: {e}", wal_dir),
796                    }
797                    .build()
798                })?;
799                let log_store = Arc::new(
800                    RaftEngineLogStore::try_new(wal_dir, raft_engine_config)
801                        .await
802                        .map_err(BoxedError::new)
803                        .context(error::BuildCliSnafu)?,
804                );
805                println!("{} Using RaftEngine WAL", "✓".green());
806                components.build(log_store).await?
807            }
808            DatanodeWalConfig::Kafka(kafka_config) if self.enable_wal => {
809                let log_store = Arc::new(
810                    KafkaLogStore::try_new(kafka_config, None)
811                        .await
812                        .map_err(BoxedError::new)
813                        .context(error::BuildCliSnafu)?,
814                );
815                println!("{} Using Kafka WAL", "✓".green());
816                components.build(log_store).await?
817            }
818            _ => {
819                let log_store = Arc::new(NoopLogStore);
820                println!(
821                    "{} Using NoopLogStore (enable_wal={})",
822                    "✓".green(),
823                    self.enable_wal
824                );
825                components.build(log_store).await?
826            }
827        };
828
829        // Open region
830        let open_request = RegionOpenRequest {
831            engine: "mito".to_string(),
832            table_dir: self.table_dir.clone(),
833            path_type,
834            options: HashMap::default(),
835            skip_wal_replay: !self.enable_wal,
836            checkpoint: None,
837            requirements: Default::default(),
838        };
839
840        engine
841            .handle_request(region_id, RegionRequest::Open(open_request))
842            .await
843            .map_err(BoxedError::new)
844            .context(error::BuildCliSnafu)?;
845        println!("{} Region opened", "✓".green());
846
847        let metadata = engine
848            .get_metadata(region_id)
849            .await
850            .map_err(BoxedError::new)
851            .context(error::BuildCliSnafu)?;
852        let scan_configs = resolve_scan_configs(&scan_config_set, &metadata)?;
853
854        // Build scan request
855        let distribution = match self.scanner.as_str() {
856            "seq" => None,
857            "unordered" => Some(TimeSeriesDistribution::TimeWindowed),
858            "series" => Some(TimeSeriesDistribution::PerSeries),
859            other => {
860                return Err(error::IllegalConfigSnafu {
861                    msg: format!(
862                        "Unknown scanner type '{}', expected: seq, unordered, series",
863                        other
864                    ),
865                }
866                .build());
867            }
868        };
869
870        let run_count = scan_config_set.run_count(self.iterations);
871        if scan_config_set.is_suite {
872            println!(
873                "{} Scanner: {}, Parallelism: {}, Queries: {}",
874                "ℹ".blue(),
875                self.scanner,
876                self.parallelism,
877                run_count,
878            );
879        } else {
880            println!(
881                "{} Scanner: {}, Parallelism: {}, Iterations: {}",
882                "ℹ".blue(),
883                self.scanner,
884                self.parallelism,
885                run_count,
886            );
887        }
888
889        // Start profiling if pprof_file is specified (unless pprof_after_warmup is set)
890        #[cfg(unix)]
891        let mut profiler_guard = if self.pprof_file.is_some() && !self.pprof_after_warmup {
892            println!("{} Starting profiling...", "⚡".yellow());
893            Some(
894                pprof::ProfilerGuardBuilder::default()
895                    .frequency(99)
896                    .blocklist(&["libc", "libgcc", "pthread", "vdso"])
897                    .build()
898                    .map_err(|e| {
899                        BoxedError::new(PlainError::new(
900                            format!("Failed to start profiler: {e}"),
901                            StatusCode::Unexpected,
902                        ))
903                    })
904                    .context(error::BuildCliSnafu)?,
905            )
906        } else {
907            None
908        };
909
910        #[cfg(not(unix))]
911        if self.pprof_file.is_some() {
912            eprintln!(
913                "{}: Profiling is not supported on this platform",
914                "Warning".yellow()
915            );
916        }
917
918        let mut total_rows_all = 0u64;
919        let mut total_elapsed_all = std::time::Duration::ZERO;
920        let mut run_results = Vec::with_capacity(if self.result_file.is_some() {
921            run_count
922        } else {
923            0
924        });
925        let mut query_summaries = scan_configs
926            .iter()
927            .map(|config| QueryRunSummary::new(config.name.clone()))
928            .collect::<Vec<_>>();
929        let collect_scanner_explain = self.verbose || self.result_file.is_some();
930
931        for iteration in 0..run_count {
932            let query_index = scan_config_set.query_index(iteration);
933            let scan_config = &scan_configs[query_index];
934            let request = ScanRequest {
935                projection: scan_config.projection.clone(),
936                filters: scan_config.filters.clone(),
937                series_row_selector: scan_config.series_row_selector,
938                distribution,
939                ..Default::default()
940            };
941
942            let start = Instant::now();
943
944            // Get scanner
945            let mut scanner = engine
946                .handle_query(region_id, request)
947                .await
948                .map_err(BoxedError::new)
949                .context(error::BuildCliSnafu)?;
950
951            // Get partition ranges and apply parallelism
952            let original_partitions = scanner.properties().partitions.clone();
953            let total_ranges: usize = original_partitions.iter().map(|p| p.len()).sum();
954
955            if self.verbose {
956                println!(
957                    "  {} Original partitions: {}, total ranges: {}",
958                    "ℹ".blue(),
959                    original_partitions.len(),
960                    total_ranges
961                );
962            }
963
964            if self.parallelism > 1 {
965                // Flatten all ranges
966                let all_ranges: Vec<_> = original_partitions.into_iter().flatten().collect();
967
968                // Distribute ranges across partitions
969                let mut partitions =
970                    ParallelizeScan::assign_partition_range(all_ranges, self.parallelism);
971
972                // Sort ranges within each partition by start time ascending
973                for partition in &mut partitions {
974                    partition.sort_by_key(|a| a.start);
975                }
976
977                scanner
978                    .prepare(
979                        PrepareRequest::default()
980                            .with_ranges(partitions)
981                            .with_target_partitions(self.parallelism),
982                    )
983                    .map_err(BoxedError::new)
984                    .context(error::BuildCliSnafu)?;
985            }
986
987            // Scan all partitions
988            let num_partitions = scanner.properties().partitions.len();
989            let ctx = QueryScanContext {
990                explain_verbose: collect_scanner_explain,
991            };
992            let metrics_set = ExecutionPlanMetricsSet::new();
993
994            let mut scan_futures = FuturesUnordered::new();
995            let setup_elapsed = start.elapsed();
996            let scan_start = Instant::now();
997
998            for partition_idx in 0..num_partitions {
999                let mut stream = scanner
1000                    .scan_partition(&ctx, &metrics_set, partition_idx)
1001                    .map_err(BoxedError::new)
1002                    .context(error::BuildCliSnafu)?;
1003
1004                scan_futures.push(tokio::spawn(async move {
1005                    let partition_start = Instant::now();
1006                    let mut rows = 0u64;
1007                    let mut batches = 0u64;
1008                    let mut array_mem_size = 0u64;
1009                    let mut estimated_size = 0u64;
1010                    let mut first_batch_elapsed = None;
1011                    while let Some(batch_result) = stream.next().await {
1012                        match batch_result {
1013                            Ok(batch) => {
1014                                if first_batch_elapsed.is_none() {
1015                                    first_batch_elapsed = Some(partition_start.elapsed());
1016                                }
1017                                batches += 1;
1018                                rows += batch.num_rows() as u64;
1019                                let df_batch = batch.df_record_batch();
1020                                array_mem_size += df_batch.get_array_memory_size() as u64;
1021                                estimated_size +=
1022                                    mito2::memtable::record_batch_estimated_size(df_batch) as u64;
1023                            }
1024                            Err(e) => {
1025                                return Err(BoxedError::new(e));
1026                            }
1027                        }
1028                    }
1029                    Ok::<PartitionScanStats, BoxedError>(PartitionScanStats {
1030                        partition: partition_idx,
1031                        rows,
1032                        batches,
1033                        array_mem_size,
1034                        estimated_size,
1035                        first_batch_elapsed,
1036                        elapsed: partition_start.elapsed(),
1037                    })
1038                }));
1039            }
1040
1041            let mut total_rows = 0u64;
1042            let mut total_batches = 0u64;
1043            let mut total_array_mem_size = 0u64;
1044            let mut total_estimated_size = 0u64;
1045            let mut partition_stats = Vec::with_capacity(num_partitions);
1046            while let Some(task) = scan_futures.next().await {
1047                let result = task
1048                    .map_err(|e| {
1049                        BoxedError::new(PlainError::new(
1050                            format!("scan task failed: {e}"),
1051                            StatusCode::Unexpected,
1052                        ))
1053                    })
1054                    .context(error::BuildCliSnafu)?;
1055                let stats = result.context(error::BuildCliSnafu)?;
1056                total_rows += stats.rows;
1057                total_batches += stats.batches;
1058                total_array_mem_size += stats.array_mem_size;
1059                total_estimated_size += stats.estimated_size;
1060                partition_stats.push(stats);
1061            }
1062            let scan_elapsed = scan_start.elapsed();
1063
1064            let elapsed = start.elapsed();
1065            total_rows_all += total_rows;
1066            total_elapsed_all += elapsed;
1067            query_summaries[query_index].record(total_rows, elapsed);
1068
1069            let query_display = if scan_config_set.is_suite {
1070                format!(
1071                    " [query {}/{}: {}]",
1072                    query_index + 1,
1073                    scan_configs.len(),
1074                    scan_config.name
1075                )
1076            } else {
1077                String::new()
1078            };
1079
1080            println!(
1081                "  [iter {}]{} {} rows in {:?} ({} partitions), array_mem_size: {}, estimated_size: {}",
1082                iteration + 1,
1083                query_display,
1084                total_rows.to_string().cyan(),
1085                elapsed,
1086                num_partitions,
1087                format_bytes(total_array_mem_size),
1088                format_bytes(total_estimated_size),
1089            );
1090
1091            if collect_scanner_explain {
1092                partition_stats.sort_unstable_by_key(|stats| stats.partition);
1093            }
1094
1095            if self.verbose {
1096                for stats in &partition_stats {
1097                    let first_batch = stats
1098                        .first_batch_elapsed
1099                        .map(|elapsed| format!("{elapsed:?}"))
1100                        .unwrap_or_else(|| "n/a".to_string());
1101                    println!(
1102                        "    partition {}: rows={}, batches={}, first_batch={}, elapsed={:?}, array_mem_size={}, estimated_size={}",
1103                        stats.partition,
1104                        stats.rows,
1105                        stats.batches,
1106                        first_batch,
1107                        stats.elapsed,
1108                        format_bytes(stats.array_mem_size),
1109                        format_bytes(stats.estimated_size),
1110                    );
1111                }
1112                if !partition_stats.is_empty() {
1113                    let total_partition_elapsed = partition_stats
1114                        .iter()
1115                        .map(|stats| stats.elapsed)
1116                        .sum::<Duration>();
1117                    let mean_partition_elapsed =
1118                        total_partition_elapsed / partition_stats.len() as u32;
1119                    let max_partition_elapsed = partition_stats
1120                        .iter()
1121                        .map(|stats| stats.elapsed)
1122                        .max()
1123                        .unwrap_or_default();
1124                    let skew = max_partition_elapsed.as_secs_f64()
1125                        / mean_partition_elapsed.as_secs_f64().max(f64::EPSILON);
1126                    println!(
1127                        "  {} Timing: setup={:?}, scan={:?}, mean_partition={:?}, max_partition={:?}, partition_skew={:.2}x",
1128                        "ℹ".blue(),
1129                        setup_elapsed,
1130                        scan_elapsed,
1131                        mean_partition_elapsed,
1132                        max_partition_elapsed,
1133                        skew,
1134                    );
1135                }
1136            }
1137
1138            let scanner_explain = if collect_scanner_explain {
1139                format!("{}", VerboseScannerDisplay(scanner.as_ref()))
1140            } else {
1141                String::new()
1142            };
1143            if self.verbose {
1144                println!("  {} Scanner explain: {}", "ℹ".blue(), scanner_explain);
1145            }
1146
1147            if self.result_file.is_some() {
1148                let source_config = &scan_config_set.configs[query_index];
1149                run_results.push(ScanRunResult {
1150                    iteration: iteration + 1,
1151                    query_index: query_index + 1,
1152                    name: scan_config.name.clone(),
1153                    config: NormalizedScanConfig {
1154                        name: scan_config.name.clone(),
1155                        projection: scan_config.projection.clone(),
1156                        filters: source_config.filters.clone().unwrap_or_default(),
1157                        series_row_selector: source_config.series_row_selector.clone(),
1158                    },
1159                    rows: total_rows,
1160                    batches: total_batches,
1161                    setup_elapsed_ns: duration_ns(setup_elapsed),
1162                    scan_elapsed_ns: duration_ns(scan_elapsed),
1163                    elapsed_ns: duration_ns(elapsed),
1164                    array_mem_size_bytes: total_array_mem_size,
1165                    estimated_size_bytes: total_estimated_size,
1166                    partitions: partition_stats
1167                        .iter()
1168                        .map(|stats| PartitionResult {
1169                            partition: stats.partition,
1170                            rows: stats.rows,
1171                            batches: stats.batches,
1172                            array_mem_size_bytes: stats.array_mem_size,
1173                            estimated_size_bytes: stats.estimated_size,
1174                            first_batch_elapsed_ns: stats.first_batch_elapsed.map(duration_ns),
1175                            elapsed_ns: duration_ns(stats.elapsed),
1176                        })
1177                        .collect(),
1178                    scanner_explain,
1179                });
1180            }
1181
1182            // Start profiling after the first iteration (warmup) if pprof_after_warmup is set
1183            #[cfg(unix)]
1184            if iteration == 0
1185                && self.pprof_after_warmup
1186                && self.pprof_file.is_some()
1187                && profiler_guard.is_none()
1188            {
1189                println!(
1190                    "{} Starting profiling after warmup iteration...",
1191                    "⚡".yellow()
1192                );
1193                profiler_guard = Some(
1194                    pprof::ProfilerGuardBuilder::default()
1195                        .frequency(99)
1196                        .blocklist(&["libc", "libgcc", "pthread", "vdso"])
1197                        .build()
1198                        .map_err(|e| {
1199                            BoxedError::new(PlainError::new(
1200                                format!("Failed to start profiler: {e}"),
1201                                StatusCode::Unexpected,
1202                            ))
1203                        })
1204                        .context(error::BuildCliSnafu)?,
1205                );
1206            }
1207        }
1208
1209        // Stop profiling and generate flamegraph if enabled
1210        #[cfg(unix)]
1211        if let (Some(guard), Some(pprof_file)) = (profiler_guard, &self.pprof_file) {
1212            println!("{} Generating flamegraph...", "🔥".yellow());
1213            match guard.report().build() {
1214                Ok(report) => {
1215                    let mut flamegraph_data = Vec::new();
1216                    if let Err(e) = report.flamegraph(&mut flamegraph_data) {
1217                        println!("{}: Failed to generate flamegraph: {}", "Error".red(), e);
1218                    } else if let Err(e) = std::fs::write(pprof_file, flamegraph_data) {
1219                        println!(
1220                            "{}: Failed to write flamegraph to {}: {}",
1221                            "Error".red(),
1222                            pprof_file.display(),
1223                            e
1224                        );
1225                    } else {
1226                        println!(
1227                            "{} Flamegraph saved to {}",
1228                            "✓".green(),
1229                            pprof_file.display().to_string().cyan()
1230                        );
1231                    }
1232                }
1233                Err(e) => {
1234                    println!("{}: Failed to generate pprof report: {}", "Error".red(), e);
1235                }
1236            }
1237        }
1238
1239        // Summary
1240        if scan_config_set.is_suite {
1241            let avg_elapsed = total_elapsed_all / run_count as u32;
1242            let avg_rows = total_rows_all / run_count as u64;
1243            println!(
1244                "\n{} Overall average: {} rows in {:?} over {} queries",
1245                "Summary".green().bold(),
1246                avg_rows.to_string().cyan(),
1247                avg_elapsed,
1248                run_count,
1249            );
1250            println!("{} Per-query:", "Summary".green().bold());
1251            for (index, summary) in query_summaries.iter().enumerate() {
1252                println!(
1253                    "  [{}] {}: runs={}, mean_rows={}, mean_elapsed={:?}",
1254                    index + 1,
1255                    summary.name,
1256                    summary.runs,
1257                    summary.mean_rows(),
1258                    summary.mean_elapsed(),
1259                );
1260            }
1261        } else if run_count > 1 {
1262            let avg_elapsed = total_elapsed_all / run_count as u32;
1263            let avg_rows = total_rows_all / run_count as u64;
1264            println!(
1265                "\n{} Average: {} rows in {:?} over {} iterations",
1266                "Summary".green().bold(),
1267                avg_rows.to_string().cyan(),
1268                avg_elapsed,
1269                run_count,
1270            );
1271        }
1272
1273        if let Some(result_file) = &self.result_file {
1274            let result = ScanbenchResult {
1275                format_version: RESULT_FORMAT_VERSION,
1276                started_at_unix_ms: benchmark_started_at_unix_ms,
1277                benchmark: BenchmarkMetadata {
1278                    scanner: self.scanner.clone(),
1279                    region_id: self.region_id.clone(),
1280                    region_id_u64: region_id.as_u64(),
1281                    table_dir: self.table_dir.clone(),
1282                    path_type: self.path_type.clone(),
1283                    parallelism: self.parallelism,
1284                    enable_wal: self.enable_wal,
1285                    config_mode: if scan_config_set.is_suite {
1286                        "suite".to_string()
1287                    } else {
1288                        "single".to_string()
1289                    },
1290                    run_count,
1291                },
1292                runs: run_results,
1293                summary: result_summary(total_rows_all, total_elapsed_all, &query_summaries),
1294            };
1295            write_result_file(result_file, &result).await?;
1296            println!(
1297                "{} Results saved to {}",
1298                "✓".green(),
1299                result_file.display().to_string().cyan()
1300            );
1301        }
1302
1303        println!("\n{}", "Benchmark completed!".green().bold());
1304        Ok(())
1305    }
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310    use datatypes::prelude::ConcreteDataType;
1311    use datatypes::schema::ColumnSchema;
1312    use sqlparser::ast::{BinaryOperator, Expr};
1313    use sqlparser::dialect::GenericDialect;
1314    use sqlparser::parser::Parser;
1315    use store_api::metadata::{ColumnMetadata, RegionMetadataBuilder};
1316    use store_api::storage::RegionId;
1317
1318    use super::{
1319        BenchmarkMetadata, BenchmarkResultSummary, NormalizedScanConfig, PartitionResult,
1320        ScanConfig, ScanRunResult, ScanbenchResult, resolve_filters, resolve_projection,
1321        validate_scan_config_suite, write_result_file,
1322    };
1323    use crate::error;
1324
1325    #[test]
1326    fn test_parse_scan_config_projection_names() {
1327        let json = r#"{"projection_names":["host","ts"]}"#;
1328        let config: ScanConfig = serde_json::from_str(json).unwrap();
1329
1330        assert_eq!(
1331            config.projection_names,
1332            Some(vec!["host".to_string(), "ts".to_string()])
1333        );
1334        assert_eq!(config.projection, None);
1335    }
1336
1337    #[test]
1338    fn test_resolve_projection_by_indexes() -> error::Result<()> {
1339        let config = ScanConfig {
1340            name: None,
1341            projection: Some(vec![0, 2]),
1342            projection_names: None,
1343            filters: None,
1344            series_row_selector: None,
1345        };
1346
1347        let projection = resolve_projection(&config, None)?;
1348        assert_eq!(projection, Some(vec![0, 2]));
1349        Ok(())
1350    }
1351
1352    #[test]
1353    fn test_resolve_projection_by_names_without_metadata() {
1354        let config = ScanConfig {
1355            name: None,
1356            projection: None,
1357            projection_names: Some(vec!["cpu".to_string(), "host".to_string()]),
1358            filters: None,
1359            series_row_selector: None,
1360        };
1361
1362        let err = resolve_projection(&config, None).unwrap_err();
1363        assert!(
1364            err.to_string()
1365                .contains("Missing region metadata while resolving 'projection_names'")
1366        );
1367    }
1368
1369    #[test]
1370    fn test_resolve_projection_conflict_fields() {
1371        let config = ScanConfig {
1372            name: None,
1373            projection: Some(vec![0]),
1374            projection_names: Some(vec!["host".to_string()]),
1375            filters: None,
1376            series_row_selector: None,
1377        };
1378
1379        let err = resolve_projection(&config, None).unwrap_err();
1380        let msg = err.to_string();
1381        assert!(msg.contains("projection"));
1382        assert!(msg.contains("projection_names"));
1383    }
1384
1385    #[test]
1386    fn test_sqlparser_parse_expr_string() {
1387        let dialect = GenericDialect {};
1388        let mut parser = Parser::new(&dialect)
1389            .try_with_sql("host = 'web-1' AND cpu > 80")
1390            .unwrap();
1391
1392        let expr = parser.parse_expr().unwrap();
1393
1394        match expr {
1395            Expr::BinaryOp { op, .. } => assert_eq!(op, BinaryOperator::And),
1396            other => panic!("expected BinaryOp, got: {other:?}"),
1397        }
1398    }
1399
1400    #[test]
1401    fn test_resolve_filters_uint32_type_conversion() {
1402        use api::v1::SemanticType;
1403
1404        let mut builder = RegionMetadataBuilder::new(RegionId::new(1, 0));
1405        builder
1406            .push_column_metadata(ColumnMetadata {
1407                column_schema: ColumnSchema::new(
1408                    "table_id",
1409                    ConcreteDataType::uint32_datatype(),
1410                    false,
1411                ),
1412                semantic_type: SemanticType::Tag,
1413                column_id: 1,
1414            })
1415            .push_column_metadata(ColumnMetadata {
1416                column_schema: ColumnSchema::new(
1417                    "ts",
1418                    ConcreteDataType::timestamp_millisecond_datatype(),
1419                    false,
1420                ),
1421                semantic_type: SemanticType::Timestamp,
1422                column_id: 2,
1423            })
1424            .primary_key(vec![1]);
1425        let metadata = builder.build().unwrap();
1426
1427        let config = ScanConfig {
1428            name: None,
1429            projection: None,
1430            projection_names: None,
1431            filters: Some(vec!["table_id = 1117".to_string()]),
1432            series_row_selector: None,
1433        };
1434
1435        let exprs = resolve_filters(&config, &metadata).unwrap();
1436        assert_eq!(exprs.len(), 1);
1437        // The expression should contain a UInt32 literal after type conversion.
1438        let expr_str = format!("{}", exprs[0]);
1439        assert!(
1440            expr_str.contains("UInt32(1117)"),
1441            "Expected UInt32(1117) in expression, got: {expr_str}"
1442        );
1443    }
1444
1445    #[test]
1446    fn test_parse_scan_config_filters() {
1447        let json = r#"{"filters":["host = 'web-1'","cpu > 80"]}"#;
1448        let config: ScanConfig = serde_json::from_str(json).unwrap();
1449
1450        assert_eq!(
1451            config.filters,
1452            Some(vec!["host = 'web-1'".to_string(), "cpu > 80".to_string()])
1453        );
1454    }
1455
1456    #[test]
1457    fn test_parse_and_validate_scan_config_suite() {
1458        let json = r#"[
1459            {"name":" cold ","filters":["host = 'web-1'"]},
1460            {"projection_names":["host","cpu"]}
1461        ]"#;
1462        let configs: Vec<ScanConfig> = serde_json::from_str(json).unwrap();
1463        let configs = validate_scan_config_suite(configs, 1).unwrap();
1464
1465        assert_eq!(2, configs.len());
1466        assert_eq!(Some("cold"), configs[0].name.as_deref());
1467        assert_eq!(Some("query-002"), configs[1].name.as_deref());
1468        assert_eq!(
1469            Some(&vec!["host = 'web-1'".to_string()]),
1470            configs[0].filters.as_ref()
1471        );
1472    }
1473
1474    #[test]
1475    fn test_validate_scan_config_suite_rejects_invalid_input() {
1476        let empty = validate_scan_config_suite(Vec::new(), 1)
1477            .unwrap_err()
1478            .to_string();
1479        assert!(empty.contains("at least one"));
1480
1481        let iterations = validate_scan_config_suite(vec![ScanConfig::default()], 2)
1482            .unwrap_err()
1483            .to_string();
1484        assert!(iterations.contains("--iterations"));
1485
1486        let configs: Vec<ScanConfig> =
1487            serde_json::from_str(r#"[{"name":"query"},{"name":" query "}]"#).unwrap();
1488        let duplicate = validate_scan_config_suite(configs, 1)
1489            .unwrap_err()
1490            .to_string();
1491        assert!(duplicate.contains("duplicate"));
1492
1493        let configs: Vec<ScanConfig> = serde_json::from_str(r#"[{"name":"  "}]"#).unwrap();
1494        let blank = validate_scan_config_suite(configs, 1)
1495            .unwrap_err()
1496            .to_string();
1497        assert!(blank.contains("empty name"));
1498    }
1499
1500    #[tokio::test]
1501    async fn test_write_result_file_overwrites_complete_result() {
1502        let dir = tempfile::tempdir().unwrap();
1503        let path = dir.path().join("scanbench-result.json");
1504        tokio::fs::write(&path, b"old result").await.unwrap();
1505
1506        let result = ScanbenchResult {
1507            format_version: 1,
1508            started_at_unix_ms: 42,
1509            benchmark: BenchmarkMetadata {
1510                scanner: "seq".to_string(),
1511                region_id: "1024:0".to_string(),
1512                region_id_u64: RegionId::new(1024, 0).as_u64(),
1513                table_dir: "greptime/public/1024".to_string(),
1514                path_type: "bare".to_string(),
1515                parallelism: 8,
1516                enable_wal: false,
1517                config_mode: "suite".to_string(),
1518                run_count: 1,
1519            },
1520            runs: vec![ScanRunResult {
1521                iteration: 1,
1522                query_index: 1,
1523                name: "cpu-host-1".to_string(),
1524                config: NormalizedScanConfig {
1525                    name: "cpu-host-1".to_string(),
1526                    projection: Some(vec![1, 2]),
1527                    filters: vec!["hostname = 'host_1'".to_string()],
1528                    series_row_selector: None,
1529                },
1530                rows: 100,
1531                batches: 2,
1532                setup_elapsed_ns: 10,
1533                scan_elapsed_ns: 80,
1534                elapsed_ns: 90,
1535                array_mem_size_bytes: 1024,
1536                estimated_size_bytes: 512,
1537                partitions: vec![PartitionResult {
1538                    partition: 0,
1539                    rows: 100,
1540                    batches: 2,
1541                    array_mem_size_bytes: 1024,
1542                    estimated_size_bytes: 512,
1543                    first_batch_elapsed_ns: Some(20),
1544                    elapsed_ns: 80,
1545                }],
1546                scanner_explain: "SeqScan: region=1024(0)".to_string(),
1547            }],
1548            summary: BenchmarkResultSummary {
1549                runs: 1,
1550                total_rows: 100,
1551                total_elapsed_ns: 90,
1552                mean_rows: 100,
1553                mean_elapsed_ns: 90,
1554                queries: vec![],
1555            },
1556        };
1557
1558        write_result_file(&path, &result).await.unwrap();
1559        let content = tokio::fs::read(&path).await.unwrap();
1560        let actual: serde_json::Value = serde_json::from_slice(&content).unwrap();
1561
1562        assert_eq!(1, actual["format_version"]);
1563        assert_eq!("suite", actual["benchmark"]["config_mode"]);
1564        assert_eq!(2, actual["runs"][0]["config"]["projection"][1]);
1565        assert_eq!(100, actual["runs"][0]["partitions"][0]["rows"]);
1566        assert_eq!(
1567            "SeqScan: region=1024(0)",
1568            actual["runs"][0]["scanner_explain"]
1569        );
1570        assert_eq!(90, actual["summary"]["mean_elapsed_ns"]);
1571    }
1572}