This is an automated email from the ASF dual-hosted git repository.

andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-ballista.git


The following commit(s) were added to refs/heads/main by this push:
     new d518d569a feat(benchmarks): persist TPC-H results resiliently and add 
a compare subcommand (#2102)
d518d569a is described below

commit d518d569a4d115adb2d697a8d95e735c8f77748f
Author: Andy Grove <[email protected]>
AuthorDate: Mon Jul 20 10:36:25 2026 -0600

    feat(benchmarks): persist TPC-H results resiliently and add a compare 
subcommand (#2102)
    
    Write the run summary after every query, atomically, so a suite killed part
    way through (e.g. an OOM SIGKILL at a large scale factor) still leaves the
    results collected so far on disk. A query that fails is recorded with an 
error
    and its completed iterations, and the run continues to the remaining queries
    instead of aborting; the process still exits non-zero if any query failed.
    
    Add a `tpch compare <baseline.json> <candidate.json>` subcommand that diffs 
two
    result files query-by-query: fastest iteration per side, delta, delta 
percent,
    suite totals, and row-count agreement.
---
 benchmarks/README.md       |  22 ++
 benchmarks/src/bin/tpch.rs | 629 ++++++++++++++++++++++++++++++++++-----------
 2 files changed, 507 insertions(+), 144 deletions(-)

diff --git a/benchmarks/README.md b/benchmarks/README.md
index a92286cea..93716965b 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -165,6 +165,28 @@ cd $ARROW_HOME/benchmarks
 cargo run --release --bin tpch benchmark ballista --host localhost --port 
50050 --query 1 --path $(pwd)/data --format tbl
 ```
 
+## Recording and comparing results
+
+Pass `--output <dir>` (works for both the `ballista` and `datafusion` benchmark
+subcommands) to write a machine-readable summary to 
`<dir>/tpch-<start_time>.json`.
+Each run records the benchmark and DataFusion versions, CPU count, the full CLI
+arguments (so the configuration is self-describing), and per-query 
per-iteration
+elapsed times and row counts.
+
+The summary is rewritten after **every query**, atomically, so a run that is
+killed part way through (for example an out-of-memory `SIGKILL` at a large 
scale
+factor) still leaves the results collected so far on disk. A query that fails 
is
+recorded with an `error` message and its completed iterations, and the run
+continues to the remaining queries instead of aborting; the process still exits
+non-zero if any query failed.
+
+Compare two summary files query-by-query — fastest iteration per side, delta,
+delta percent, suite totals, and row-count agreement:
+
+```bash
+cargo run --release --bin tpch compare baseline.json candidate.json
+```
+
 ## Running the Ballista Benchmarks on docker-compose
 
 The `docker-compose.yml` at the repo root brings up one scheduler and two
diff --git a/benchmarks/src/bin/tpch.rs b/benchmarks/src/bin/tpch.rs
index 5c561b171..2650f3401 100644
--- a/benchmarks/src/bin/tpch.rs
+++ b/benchmarks/src/bin/tpch.rs
@@ -56,7 +56,7 @@ use datafusion::{
 };
 use futures::future::join_all;
 use rand::prelude::*;
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
 use std::ops::Div;
 use std::{
     fs::{self, File},
@@ -266,12 +266,25 @@ enum LoadtestOpt {
     BallistaLoadtest(BallistaLoadtestOpt),
 }
 
+#[derive(Debug, StructOpt)]
+struct CompareOpt {
+    /// Baseline result JSON (written by a benchmark run's `--output` 
directory).
+    #[structopt(parse(from_os_str))]
+    baseline: PathBuf,
+
+    /// Candidate result JSON to compare against the baseline.
+    #[structopt(parse(from_os_str))]
+    candidate: PathBuf,
+}
+
 #[derive(Debug, StructOpt)]
 #[structopt(name = "TPC-H", about = "TPC-H Benchmarks.")]
 enum TpchOpt {
     Benchmark(BenchmarkSubCommandOpt),
     Convert(ConvertOpt),
     Loadtest(LoadtestOpt),
+    /// Compare two benchmark result JSON files query-by-query.
+    Compare(CompareOpt),
 }
 
 const TABLES: &[&str] = &[
@@ -295,6 +308,7 @@ async fn main() -> Result<()> {
         TpchOpt::Loadtest(BallistaLoadtest(opt)) => {
             loadtest_ballista(opt).await.map(|_| ())
         }
+        TpchOpt::Compare(opt) => run_compare(opt),
     }
 }
 
@@ -352,61 +366,126 @@ async fn benchmark_datafusion(opt: 
DataFusionBenchmarkOpt) -> Result<Vec<RecordB
     let mut result: Vec<RecordBatch> = Vec::with_capacity(1);
     let mut total_elapsed = 0.0;
 
-    for query in query_numbers {
-        let mut query_run = QueryRun::new(query);
-        let mut secs = vec![];
+    let mut any_failed = false;
 
-        // run benchmark
+    for query in query_numbers {
         let sqls = get_query_sql(query)?;
         if opt.debug {
             println!("Query {query}:\n{sqls:?}");
         }
-        for i in 0..opt.iterations {
-            let start = Instant::now();
-            // Execute each SQL statement sequentially (required for queries 
like q15
-            // that create views and then reference them), keeping the result 
of
-            // the answer statement, not a trailing DROP VIEW.
-            result = execute_query_capturing_answer(&ctx, &sqls, 
opt.debug).await?;
-            let elapsed = start.elapsed().as_secs_f64();
-            if opt.debug {
-                pretty::print_batches(&result)?;
+        let mut query_run = QueryRun::new(query);
+        match run_local_query(
+            &ctx,
+            query,
+            &sqls,
+            opt.iterations,
+            opt.debug,
+            &mut query_run,
+        )
+        .await
+        {
+            Ok(batches) => {
+                result = batches;
+                accumulate_total(query, &query_run, opt.iterations, &mut 
total_elapsed);
             }
-            secs.push(elapsed);
-            let row_count = result.iter().map(|b| b.num_rows()).sum();
-            if opt.iterations == 1 {
-                println!(
-                    "Query {} took {:.3} s and returned {} rows",
-                    query, elapsed, row_count
-                );
-            } else {
-                println!(
-                    "Query {} iteration {} took {:.3} s and returned {} rows",
-                    query, i, elapsed, row_count
-                );
+            Err(e) => {
+                eprintln!("Query {query} failed: {e}");
+                query_run.error = Some(e.to_string());
+                any_failed = true;
             }
-            query_run.add_result(elapsed, row_count);
         }
-
-        if opt.iterations > 1 {
-            let avg = secs.iter().sum::<f64>() / secs.len() as f64;
-            println!("Query {} avg time: {:.3} s", query, avg);
-            total_elapsed += avg;
-        } else {
-            total_elapsed += secs.iter().sum::<f64>();
-        }
-
         benchmark_run.add_query_run(query_run);
+        // Persist after every query so a hard kill mid-suite still leaves the
+        // results collected so far on disk.
+        persist_summary(&benchmark_run, opt.output_path.as_deref())?;
     }
 
     println!("Total time: {total_elapsed:.3} s");
+    if let Some(path) = persist_summary(&benchmark_run, 
opt.output_path.as_deref())? {
+        println!("Summary written to {}", path.display());
+    }
 
-    if let Some(path) = &opt.output_path {
-        write_summary_json(&benchmark_run, path)?;
+    if any_failed {
+        return Err(DataFusionError::Execution(
+            "one or more queries failed; see the summary for 
details".to_string(),
+        ));
     }
+    Ok(result)
+}
 
+/// Run one query `iterations` times against a local DataFusion context, 
pushing
+/// each iteration's timing into `query_run`, and return the answer batches 
from
+/// the last iteration. On error, `query_run` already holds whatever iterations
+/// completed before the failure.
+async fn run_local_query(
+    ctx: &SessionContext,
+    query: usize,
+    sqls: &[String],
+    iterations: usize,
+    debug: bool,
+    query_run: &mut QueryRun,
+) -> Result<Vec<RecordBatch>> {
+    let mut result = vec![];
+    for i in 0..iterations {
+        let start = Instant::now();
+        // Execute each SQL statement sequentially (required for queries like 
q15
+        // that create views and then reference them), keeping the result of 
the
+        // answer statement, not a trailing DROP VIEW.
+        result = execute_query_capturing_answer(ctx, sqls, debug).await?;
+        let elapsed = start.elapsed().as_secs_f64();
+        if debug {
+            pretty::print_batches(&result)?;
+        }
+        let row_count = result.iter().map(|b| b.num_rows()).sum();
+        print_iteration(query, i, iterations, elapsed, row_count);
+        query_run.add_result(elapsed, row_count);
+    }
     Ok(result)
 }
 
+fn print_iteration(
+    query: usize,
+    iteration: usize,
+    iterations: usize,
+    elapsed: f64,
+    row_count: usize,
+) {
+    if iterations == 1 {
+        println!("Query {query} took {elapsed:.3} s and returned {row_count} 
rows");
+    } else {
+        println!(
+            "Query {query} iteration {iteration} took {elapsed:.3} s and 
returned {row_count} rows"
+        );
+    }
+}
+
+/// Add a completed query's contribution to the running suite total: the 
average
+/// iteration time when repeating, else the single run.
+fn accumulate_total(
+    query: usize,
+    query_run: &QueryRun,
+    iterations: usize,
+    total: &mut f64,
+) {
+    let secs: Vec<f64> = query_run.iterations.iter().map(|r| 
r.elapsed).collect();
+    if secs.is_empty() {
+        return;
+    }
+    if iterations > 1 {
+        let avg = secs.iter().sum::<f64>() / secs.len() as f64;
+        println!("Query {query} avg time: {avg:.3} s");
+        *total += avg;
+    } else {
+        *total += secs.iter().sum::<f64>();
+    }
+}
+
+/// Write the run summary to `dir` if one was configured; a no-op otherwise.
+/// Returns the written path when it wrote one.
+fn persist_summary(run: &BenchmarkRun, dir: Option<&Path>) -> 
Result<Option<PathBuf>> {
+    dir.map(|d| write_summary_json(run, d)).transpose()
+}
+
 async fn benchmark_ballista(opt: BallistaBenchmarkOpt) -> Result<()> {
     println!("Running benchmarks with the following options: {opt:?}");
 
@@ -442,135 +521,282 @@ async fn benchmark_ballista(opt: BallistaBenchmarkOpt) 
-> Result<()> {
     let mut benchmark_run = BenchmarkRun::new();
     let mut total_elapsed = 0.0;
 
+    let mut any_failed = false;
+
     for query in query_numbers {
+        let sqls = get_query_sql(query)?;
+        if opt.debug {
+            println!("Running benchmark with query {}:\n {:?}", query, sqls);
+        }
         let mut query_run = QueryRun::new(query);
-
-        let mut config = session_config_with_s3_support()
-            .with_target_partitions(opt.partitions)
-            .with_ballista_job_name(&format!("Query derived from TPC-H q{}", 
query))
-            .with_batch_size(opt.batch_size)
-            .with_collect_statistics(true);
-
-        for kv in &opt.config_overrides {
-            if let Some((key, value)) = kv.split_once('=') {
-                if let Err(e) = config.options_mut().set(key.trim(), 
value.trim()) {
-                    println!("Warning: could not set config '{}': {}", kv, e);
-                }
-            } else {
-                println!(
-                    "Warning: ignoring invalid config override '{}'. \
-                     Expected format: key=value",
-                    kv
-                );
+        match run_ballista_query(
+            &address,
+            &opt,
+            oracle_ctx.as_ref(),
+            query,
+            &sqls,
+            &mut query_run,
+        )
+        .await
+        {
+            Ok(()) => {
+                accumulate_total(query, &query_run, opt.iterations, &mut 
total_elapsed);
+            }
+            Err(e) => {
+                eprintln!("Query {query} failed: {e}");
+                query_run.error = Some(e.to_string());
+                any_failed = true;
             }
         }
+        benchmark_run.add_query_run(query_run);
+        // Persist after every query so a hard kill mid-suite still leaves the
+        // results collected so far on disk.
+        persist_summary(&benchmark_run, opt.output_path.as_deref())?;
+    }
 
-        let state = session_state_with_s3_support(config)?;
-        let ctx = SessionContext::remote_with_state(&address, state).await?;
+    println!("Total time: {total_elapsed:.3} s");
+    if let Some(path) = persist_summary(&benchmark_run, 
opt.output_path.as_deref())? {
+        println!("Summary written to {}", path.display());
+    }
 
-        // register tables with Ballista context
-        let path = opt.path.as_str();
-        let file_format = opt.file_format.as_str();
+    if any_failed {
+        return Err(DataFusionError::Execution(
+            "one or more queries failed; see the summary for 
details".to_string(),
+        ));
+    }
+    Ok(())
+}
 
-        register_tables(path, file_format, &ctx, opt.debug).await?;
+/// Run one query `iterations` times against the Ballista cluster at `address`,
+/// pushing each iteration's timing into `query_run`. A fresh session is built
+/// per query so config overrides and the job name apply per query. When
+/// `oracle_ctx` is set, the answer is verified against local DataFusion. On
+/// error, `query_run` holds whatever iterations completed before the failure.
+async fn run_ballista_query(
+    address: &str,
+    opt: &BallistaBenchmarkOpt,
+    oracle_ctx: Option<&SessionContext>,
+    query: usize,
+    sqls: &[String],
+    query_run: &mut QueryRun,
+) -> Result<()> {
+    let mut config = session_config_with_s3_support()
+        .with_target_partitions(opt.partitions)
+        .with_ballista_job_name(&format!("Query derived from TPC-H q{}", 
query))
+        .with_batch_size(opt.batch_size)
+        .with_collect_statistics(true);
+
+    for kv in &opt.config_overrides {
+        if let Some((key, value)) = kv.split_once('=') {
+            if let Err(e) = config.options_mut().set(key.trim(), value.trim()) 
{
+                println!("Warning: could not set config '{}': {}", kv, e);
+            }
+        } else {
+            println!(
+                "Warning: ignoring invalid config override '{}'. \
+                 Expected format: key=value",
+                kv
+            );
+        }
+    }
 
-        let mut secs = vec![];
+    let state = session_state_with_s3_support(config)?;
+    let ctx = SessionContext::remote_with_state(address, state).await?;
+    register_tables(opt.path.as_str(), opt.file_format.as_str(), &ctx, 
opt.debug).await?;
 
-        // run benchmark
-        let sqls = get_query_sql(query)?;
-        if opt.debug {
-            println!("Running benchmark with query {}:\n {:?}", query, sqls);
-        }
-        let mut batches = vec![];
-        let answer_idx = answer_statement_index(&sqls);
-        for i in 0..opt.iterations {
-            let start = Instant::now();
-            for (idx, sql) in sqls.iter().enumerate() {
-                let df = ctx
-                    .sql(sql)
-                    .await
-                    .map_err(|e| DataFusionError::Plan(format!("{e:?}")))
-                    .unwrap();
-                let plan = df.clone().into_optimized_plan()?;
-                if opt.debug {
-                    println!("=== Optimized logical plan ===\n{plan:?}\n");
-                }
-                let collected = df
-                    .collect()
-                    .await
-                    .map_err(|e| DataFusionError::Plan(format!("{e:?}")))
-                    .unwrap();
-                if idx == answer_idx {
-                    batches = collected;
-                }
-            }
-            let elapsed = start.elapsed().as_secs_f64();
-            secs.push(elapsed);
-            let row_count = batches.iter().map(|b| b.num_rows()).sum();
-            if opt.iterations == 1 {
-                println!(
-                    "Query {} took {:.3} s and returned {} rows",
-                    query, elapsed, row_count
-                );
-            } else {
-                println!(
-                    "Query {} iteration {} took {:.3} s and returned {} rows",
-                    query, i, elapsed, row_count
-                );
-            }
-            query_run.add_result(elapsed, row_count);
+    let answer_idx = answer_statement_index(sqls);
+    let mut batches = vec![];
+    for i in 0..opt.iterations {
+        let start = Instant::now();
+        for (idx, sql) in sqls.iter().enumerate() {
+            let df = ctx
+                .sql(sql)
+                .await
+                .map_err(|e| DataFusionError::Plan(format!("{e:?}")))?;
+            let plan = df.clone().into_optimized_plan()?;
             if opt.debug {
-                pretty::print_batches(&batches)?;
+                println!("=== Optimized logical plan ===\n{plan:?}\n");
             }
-
-            if let Some(expected_results_path) = opt.expected_results.as_ref() 
{
-                let expected = get_expected_results(query, 
expected_results_path).await?;
-                compare_results(&expected, &batches)?;
+            let collected = df
+                .collect()
+                .await
+                .map_err(|e| DataFusionError::Plan(format!("{e:?}")))?;
+            if idx == answer_idx {
+                batches = collected;
             }
         }
-
-        if let Some(oracle_ctx) = &oracle_ctx {
-            let expected =
-                execute_query_capturing_answer(oracle_ctx, &sqls, 
opt.debug).await?;
-            compare_results(&expected, &batches).map_err(|e| {
-                DataFusionError::Execution(format!(
-                    "query {query} verification failed: {e}"
-                ))
-            })?;
-            println!("Query {query} verified against DataFusion: OK");
+        let elapsed = start.elapsed().as_secs_f64();
+        let row_count = batches.iter().map(|b| b.num_rows()).sum();
+        print_iteration(query, i, opt.iterations, elapsed, row_count);
+        query_run.add_result(elapsed, row_count);
+        if opt.debug {
+            pretty::print_batches(&batches)?;
         }
 
-        if opt.iterations > 1 {
-            let avg = secs.iter().sum::<f64>() / secs.len() as f64;
-            println!("Query {} avg time: {:.3} s", query, avg);
-            total_elapsed += avg;
-        } else {
-            total_elapsed += secs.iter().sum::<f64>();
+        if let Some(expected_results_path) = opt.expected_results.as_ref() {
+            let expected = get_expected_results(query, 
expected_results_path).await?;
+            compare_results(&expected, &batches)?;
         }
-
-        benchmark_run.add_query_run(query_run);
     }
 
-    println!("Total time: {total_elapsed:.3} s");
-
-    if let Some(path) = &opt.output_path {
-        write_summary_json(&benchmark_run, path)?;
+    if let Some(oracle_ctx) = oracle_ctx {
+        let expected =
+            execute_query_capturing_answer(oracle_ctx, sqls, opt.debug).await?;
+        compare_results(&expected, &batches).map_err(|e| {
+            DataFusionError::Execution(format!("query {query} verification 
failed: {e}"))
+        })?;
+        println!("Query {query} verified against DataFusion: OK");
     }
-
     Ok(())
 }
 
-fn write_summary_json(benchmark_run: &BenchmarkRun, path: &Path) -> Result<()> 
{
+/// Write (or overwrite) the run summary as `tpch-<start_time>.json` in `dir`.
+///
+/// The write is atomic — a temp file is written then renamed over the target —
+/// so callers can persist after every query without risking a truncated file 
if
+/// the process is killed (e.g. an OOM SIGKILL) mid-write. Returns the final 
path.
+fn write_summary_json(benchmark_run: &BenchmarkRun, dir: &Path) -> 
Result<PathBuf> {
     let json =
         serde_json::to_string_pretty(&benchmark_run).expect("summary is 
serializable");
-    let filename = format!("tpch-{}.json", benchmark_run.start_time);
-    let path = path.join(filename);
+    let final_path = dir.join(format!("tpch-{}.json", 
benchmark_run.start_time));
+    let tmp_path = dir.join(format!("tpch-{}.json.tmp", 
benchmark_run.start_time));
+    {
+        let mut file = File::create(&tmp_path)?;
+        file.write_all(json.as_bytes())?;
+        file.sync_all()?;
+    }
+    fs::rename(&tmp_path, &final_path)?;
+    Ok(final_path)
+}
+
+/// Per-query comparison of two benchmark runs, keyed by query number. A side 
is
+/// `None` when that run has no completed iteration for the query (absent or
+/// failed).
+struct QueryComparison {
+    query: usize,
+    baseline: Option<QueryStat>,
+    candidate: Option<QueryStat>,
+}
+
+/// The comparable summary of one query in one run: its fastest iteration and 
the
+/// row count it returned.
+#[derive(Clone, Copy)]
+struct QueryStat {
+    min_elapsed: f64,
+    row_count: Option<usize>,
+}
+
+fn query_stat(run: &QueryRun) -> Option<QueryStat> {
+    run.min_elapsed().map(|min_elapsed| QueryStat {
+        min_elapsed,
+        row_count: run.row_count(),
+    })
+}
+
+/// Join two runs' query results by query number (union, ascending). The metric
+/// per side is the fastest iteration, matching how benchmark totals are read.
+fn compare_benchmark_runs(
+    baseline: &BenchmarkRun,
+    candidate: &BenchmarkRun,
+) -> Vec<QueryComparison> {
+    let mut queries: Vec<usize> = baseline
+        .queries
+        .iter()
+        .chain(candidate.queries.iter())
+        .map(|q| q.query)
+        .collect();
+    queries.sort_unstable();
+    queries.dedup();
+
+    let stat_of = |run: &BenchmarkRun, query: usize| -> Option<QueryStat> {
+        run.queries
+            .iter()
+            .find(|q| q.query == query)
+            .and_then(query_stat)
+    };
+
+    queries
+        .into_iter()
+        .map(|query| QueryComparison {
+            query,
+            baseline: stat_of(baseline, query),
+            candidate: stat_of(candidate, query),
+        })
+        .collect()
+}
+
+fn read_benchmark_run(path: &Path) -> Result<BenchmarkRun> {
+    let bytes = fs::read(path).map_err(|e| {
+        DataFusionError::Execution(format!("could not read {}: {e}", 
path.display()))
+    })?;
+    serde_json::from_slice(&bytes).map_err(|e| {
+        DataFusionError::Execution(format!("could not parse {}: {e}", 
path.display()))
+    })
+}
+
+fn run_compare(opt: CompareOpt) -> Result<()> {
+    let baseline = read_benchmark_run(&opt.baseline)?;
+    let candidate = read_benchmark_run(&opt.candidate)?;
+    let comparisons = compare_benchmark_runs(&baseline, &candidate);
+
+    println!(
+        "{:>5}  {:>12}  {:>12}  {:>11}  {:>8}  rows",
+        "query", "baseline(s)", "candidate(s)", "delta(s)", "delta%"
+    );
+
+    let (mut base_total, mut cand_total) = (0.0_f64, 0.0_f64);
+    for c in &comparisons {
+        let cell = |s: Option<QueryStat>| {
+            s.map(|s| format!("{:.3}", s.min_elapsed))
+                .unwrap_or_else(|| "FAIL".to_string())
+        };
+        let (delta, pct) = match (c.baseline, c.candidate) {
+            (Some(b), Some(cnd)) => {
+                base_total += b.min_elapsed;
+                cand_total += cnd.min_elapsed;
+                let d = cnd.min_elapsed - b.min_elapsed;
+                (
+                    format!("{d:+.3}"),
+                    format!("{:+.1}%", 100.0 * d / b.min_elapsed),
+                )
+            }
+            _ => ("-".to_string(), "-".to_string()),
+        };
+        let rows = match (
+            c.baseline.and_then(|s| s.row_count),
+            c.candidate.and_then(|s| s.row_count),
+        ) {
+            (Some(b), Some(cnd)) if b == cnd => format!("{b}"),
+            (Some(b), Some(cnd)) => format!("{b}/{cnd} MISMATCH"),
+            (Some(b), None) => format!("{b}/-"),
+            (None, Some(cnd)) => format!("-/{cnd}"),
+            (None, None) => "-".to_string(),
+        };
+        println!(
+            "{:>5}  {:>12}  {:>12}  {:>11}  {:>8}  {}",
+            c.query,
+            cell(c.baseline),
+            cell(c.candidate),
+            delta,
+            pct,
+            rows
+        );
+    }
+
+    let total_delta = cand_total - base_total;
+    let total_pct = if base_total > 0.0 {
+        format!("{:+.1}%", 100.0 * total_delta / base_total)
+    } else {
+        "-".to_string()
+    };
     println!(
-        "Writing summary file to {}",
-        path.as_os_str().to_str().unwrap()
+        "{:>5}  {:>12.3}  {:>12.3}  {:>11}  {:>8}  (queries completed by 
both)",
+        "Σ",
+        base_total,
+        cand_total,
+        format!("{total_delta:+.3}"),
+        total_pct
     );
-    let mut file = File::create(path)?;
-    file.write_all(json.as_bytes())?;
     Ok(())
 }
 
@@ -1104,12 +1330,17 @@ pub fn get_tbl_tpch_table_schema(table: &str) -> Schema 
{
     schema.finish()
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 struct QueryRun {
     /// query number
     query: usize,
     /// list of individual run times and row counts
     iterations: Vec<QueryResult>,
+    /// Set when the query did not run all iterations to completion. The 
message
+    /// captures why; `iterations` then holds only the iterations that finished
+    /// before the failure (possibly none).
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    error: Option<String>,
 }
 
 impl QueryRun {
@@ -1117,15 +1348,30 @@ impl QueryRun {
         Self {
             query,
             iterations: vec![],
+            error: None,
         }
     }
 
     fn add_result(&mut self, elapsed: f64, row_count: usize) {
         self.iterations.push(QueryResult { elapsed, row_count })
     }
+
+    /// Fastest completed iteration in seconds, or `None` if no iteration
+    /// finished (e.g. the query failed before completing one).
+    fn min_elapsed(&self) -> Option<f64> {
+        self.iterations
+            .iter()
+            .map(|r| r.elapsed)
+            .min_by(|a, b| a.total_cmp(b))
+    }
+
+    /// Row count from the first completed iteration.
+    fn row_count(&self) -> Option<usize> {
+        self.iterations.first().map(|r| r.row_count)
+    }
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 struct BenchmarkRun {
     /// Benchmark crate version
     benchmark_version: String,
@@ -1369,7 +1615,7 @@ fn string_schema(schema: Schema) -> Schema {
     )
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 struct QueryResult {
     elapsed: f64,
     row_count: usize,
@@ -1381,6 +1627,101 @@ mod tests {
     use std::env;
     use std::sync::Arc;
 
+    fn run_with(queries: Vec<QueryRun>) -> BenchmarkRun {
+        BenchmarkRun {
+            benchmark_version: "test".to_string(),
+            datafusion_version: "test".to_string(),
+            num_cpus: 1,
+            start_time: 0,
+            arguments: vec![],
+            queries,
+        }
+    }
+
+    fn query_run_with(query: usize, times: &[f64], rows: usize) -> QueryRun {
+        let mut qr = QueryRun::new(query);
+        for &t in times {
+            qr.add_result(t, rows);
+        }
+        qr
+    }
+
+    #[test]
+    fn min_elapsed_picks_fastest_iteration() {
+        let qr = query_run_with(1, &[3.0, 1.5, 2.0], 4);
+        assert_eq!(qr.min_elapsed(), Some(1.5));
+        assert_eq!(qr.row_count(), Some(4));
+    }
+
+    #[test]
+    fn min_elapsed_is_none_without_iterations() {
+        let qr = QueryRun::new(7);
+        assert_eq!(qr.min_elapsed(), None);
+        assert_eq!(qr.row_count(), None);
+    }
+
+    #[test]
+    fn success_run_omits_error_and_roundtrips() {
+        let qr = query_run_with(1, &[1.0], 4);
+        let json = serde_json::to_string(&qr).unwrap();
+        assert!(
+            !json.contains("error"),
+            "success run should omit error: {json}"
+        );
+        let back: QueryRun = serde_json::from_str(&json).unwrap();
+        assert!(back.error.is_none());
+        assert_eq!(back.iterations.len(), 1);
+    }
+
+    #[test]
+    fn failed_run_keeps_partial_iterations_and_error() {
+        let mut qr = query_run_with(5, &[2.0], 3); // one iteration completed
+        qr.error = Some("boom".to_string());
+        let json = serde_json::to_string(&qr).unwrap();
+        let back: QueryRun = serde_json::from_str(&json).unwrap();
+        assert_eq!(back.error.as_deref(), Some("boom"));
+        assert_eq!(back.min_elapsed(), Some(2.0)); // partial iteration 
preserved
+    }
+
+    #[test]
+    fn compare_unions_queries_uses_min_and_tracks_rows() {
+        let baseline = run_with(vec![
+            query_run_with(1, &[10.0, 12.0], 4),
+            query_run_with(2, &[100.0], 100),
+            query_run_with(3, &[50.0], 10),
+        ]);
+        let candidate = run_with(vec![
+            query_run_with(1, &[9.0, 11.0], 4), // faster, rows match
+            query_run_with(2, &[120.0], 101),   // slower, rows differ
+            // q3 absent in candidate
+            query_run_with(4, &[5.0], 1), // only in candidate
+        ]);
+
+        let cmp = compare_benchmark_runs(&baseline, &candidate);
+        assert_eq!(
+            cmp.iter().map(|c| c.query).collect::<Vec<_>>(),
+            vec![1, 2, 3, 4]
+        );
+
+        assert_eq!(cmp[0].baseline.unwrap().min_elapsed, 10.0);
+        assert_eq!(cmp[0].candidate.unwrap().min_elapsed, 9.0);
+
+        assert_eq!(cmp[1].baseline.unwrap().row_count, Some(100));
+        assert_eq!(cmp[1].candidate.unwrap().row_count, Some(101));
+
+        assert!(cmp[2].baseline.is_some() && cmp[2].candidate.is_none());
+        assert!(cmp[3].baseline.is_none() && cmp[3].candidate.is_some());
+    }
+
+    #[test]
+    fn failed_query_yields_no_comparable_stat() {
+        let mut qr = QueryRun::new(9);
+        qr.error = Some("failed before any iteration".to_string());
+        let run = run_with(vec![qr]);
+        let cmp = compare_benchmark_runs(&run, &run);
+        assert!(cmp[0].baseline.is_none());
+    }
+
     #[tokio::test]
     async fn q1() -> Result<()> {
         verify_query(1).await


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to