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

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


The following commit(s) were added to refs/heads/main by this push:
     new e583fe9d22 Add deterministic per-file timing summary to sqllogictest 
runner (#20569)
e583fe9d22 is described below

commit e583fe9d227adbd37ebad6043e1751cc71442b77
Author: kosiew <[email protected]>
AuthorDate: Fri Feb 27 17:43:47 2026 +0800

    Add deterministic per-file timing summary to sqllogictest runner (#20569)
    
    ## Which issue does this PR close?
    
    * Part of #20524.
    
    ## Rationale for this change
    
    The sqllogictest runner executes files in parallel, but it was hard to
    pinpoint which test files dominate wall-clock time. This change adds
    **deterministic per-file elapsed timing observability** so we can
    identify long-tail files and prioritize follow-up optimization work,
    while keeping default output usable for both local development (TTY) and
    CI (non-TTY).
    
    ## What changes are included in this PR?
    
    * Collect per-file elapsed durations in the sqllogictest runner and
    aggregate them at end-of-run.
    * Print a **deterministic timing summary** (stable sort: elapsed desc,
    path asc; stable formatting) via `MultiProgress` to avoid interleaved
    progress-bar noise.
    * Add CLI flags and environment variables to control output:
    
      * `--timing-summary auto|off|top|full` (also `SLT_TIMING_SUMMARY`)
      * `--timing-top-n <N>` (also `SLT_TIMING_TOP_N`, must be `>= 1`)
    * Default behavior:
    
    * `auto` maps to `off` for local TTY runs and `top` for CI/non-TTY runs.
    * Add optional debug logging for slow files (over 30s) behind
    `SLT_TIMING_DEBUG_SLOW_FILES=1`.
    * Update `datafusion/sqllogictest/README.md` with usage examples.
    
    ## Are these changes tested?
    
    * Covered by existing `sqllogictests` integration test execution; no new
    unit tests were added.
    * Manual validation plan (ran locally / in CI as applicable):
    
    * `cargo test --test sqllogictests -- push_down_filter_ --test-threads
    16`
      * `cargo test --test sqllogictests -- --test-threads 16`
    * `cargo test --test sqllogictests -- --timing-summary top
    --timing-top-n 10`
      * `cargo test --test sqllogictests -- --timing-summary full`
    * Verified output properties:
    
    * Summary ordering is deterministic across repeated runs (elapsed desc,
    path asc).
    * `auto` mode is quiet on TTY but prints a top-N summary on non-TTY/CI.
      * Pass/fail behavior and error reporting are unchanged.
    
    ## Are there any user-facing changes?
    
    Yes (test-runner UX only):
    
    * New optional timing summary output for `sqllogictests`.
    * New CLI flags / env vars documented in
    `datafusion/sqllogictest/README.md`:
    
      * `--timing-summary auto|off|top|full` / `SLT_TIMING_SUMMARY`
      * `--timing-top-n <N>` / `SLT_TIMING_TOP_N`
    * `SLT_TIMING_DEBUG_SLOW_FILES=1` (optional debug logging for slow files
    >30s)
    
    No public DataFusion APIs are changed.
    
    ## LLM-generated code disclosure
    
    This PR includes LLM-generated code and comments. All LLM-generated
    content has been manually reviewed and tested.
---
 datafusion/sqllogictest/README.md            |  30 +++++
 datafusion/sqllogictest/bin/sqllogictests.rs | 181 ++++++++++++++++++++++++---
 2 files changed, 192 insertions(+), 19 deletions(-)

diff --git a/datafusion/sqllogictest/README.md 
b/datafusion/sqllogictest/README.md
index 8768deee3d..7d84ad23d5 100644
--- a/datafusion/sqllogictest/README.md
+++ b/datafusion/sqllogictest/README.md
@@ -70,6 +70,36 @@ cargo test --test sqllogictests -- ddl --complete
 RUST_LOG=debug cargo test --test sqllogictests -- ddl
 ```
 
+### Per-file timing summary
+
+The sqllogictest runner can emit deterministic per-file elapsed timings to help
+identify slow test files.
+
+By default (`--timing-summary auto`), timing summary output is disabled in 
local
+TTY runs and shows a top-slowest summary in non-TTY/CI runs.
+
+`--timing-top-n` / `SLT_TIMING_TOP_N` must be a positive integer (`>= 1`).
+
+```shell
+# Show top 10 slowest files (good for CI)
+cargo test --test sqllogictests -- --timing-summary top --timing-top-n 10
+```
+
+```shell
+# Show full per-file timing table
+cargo test --test sqllogictests -- --timing-summary full
+```
+
+```shell
+# Same controls via environment variables
+SLT_TIMING_SUMMARY=top SLT_TIMING_TOP_N=15 cargo test --test sqllogictests
+```
+
+```shell
+# Optional debug logging for per-task slow files (>30s), disabled by default
+SLT_TIMING_DEBUG_SLOW_FILES=1 cargo test --test sqllogictests
+```
+
 ## Cookbook: Adding Tests
 
 1. Add queries
diff --git a/datafusion/sqllogictest/bin/sqllogictests.rs 
b/datafusion/sqllogictest/bin/sqllogictests.rs
index 463b7b03a7..e067f2488d 100644
--- a/datafusion/sqllogictest/bin/sqllogictests.rs
+++ b/datafusion/sqllogictest/bin/sqllogictests.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use clap::{ColorChoice, Parser};
+use clap::{ColorChoice, Parser, ValueEnum};
 use datafusion::common::instant::Instant;
 use datafusion::common::utils::get_available_parallelism;
 use datafusion::common::{DataFusionError, Result, exec_datafusion_err, 
exec_err};
@@ -49,6 +49,7 @@ use std::path::{Path, PathBuf};
 use std::str::FromStr;
 use std::sync::Arc;
 use std::sync::atomic::{AtomicUsize, Ordering};
+use std::time::Duration;
 
 #[cfg(feature = "postgres")]
 mod postgres_container;
@@ -58,6 +59,21 @@ const DATAFUSION_TESTING_TEST_DIRECTORY: &str = 
"../../datafusion-testing/data/"
 const PG_COMPAT_FILE_PREFIX: &str = "pg_compat_";
 const SQLITE_PREFIX: &str = "sqlite";
 const ERRS_PER_FILE_LIMIT: usize = 10;
+const TIMING_DEBUG_SLOW_FILES_ENV: &str = "SLT_TIMING_DEBUG_SLOW_FILES";
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
+enum TimingSummaryMode {
+    Auto,
+    Off,
+    Top,
+    Full,
+}
+
+#[derive(Debug)]
+struct FileTiming {
+    relative_path: PathBuf,
+    elapsed: Duration,
+}
 
 pub fn main() -> Result<()> {
     tokio::runtime::Builder::new_multi_thread()
@@ -100,6 +116,7 @@ async fn run_tests() -> Result<()> {
     env_logger::init();
 
     let options: Options = Parser::parse();
+    let timing_debug_slow_files = is_env_truthy(TIMING_DEBUG_SLOW_FILES_ENV);
     if options.list {
         // nextest parses stdout, so print messages to stderr
         eprintln!("NOTICE: --list option unsupported, quitting");
@@ -160,7 +177,7 @@ async fn run_tests() -> Result<()> {
     let is_ci = !stderr().is_terminal();
     let completed_count = Arc::new(AtomicUsize::new(0));
 
-    let errors: Vec<_> = futures::stream::iter(test_files)
+    let file_results: Vec<_> = futures::stream::iter(test_files)
         .map(|test_file| {
             let validator = if options.include_sqlite
                 && test_file.relative_path.starts_with(SQLITE_PREFIX)
@@ -182,7 +199,7 @@ async fn run_tests() -> Result<()> {
                 currently_running_sql_tracker.clone();
             let file_start = Instant::now();
             SpawnedTask::spawn(async move {
-                match (
+                let result = match (
                     options.postgres_runner,
                     options.complete,
                     options.substrait_round_trip,
@@ -197,7 +214,7 @@ async fn run_tests() -> Result<()> {
                             currently_running_sql_tracker_clone,
                             colored_output,
                         )
-                        .await?
+                        .await
                     }
                     (false, false, _) => {
                         run_test_file(
@@ -209,7 +226,7 @@ async fn run_tests() -> Result<()> {
                             currently_running_sql_tracker_clone,
                             colored_output,
                         )
-                        .await?
+                        .await
                     }
                     (false, true, _) => {
                         run_complete_file(
@@ -219,7 +236,7 @@ async fn run_tests() -> Result<()> {
                             m_style_clone,
                             currently_running_sql_tracker_clone,
                         )
-                        .await?
+                        .await
                     }
                     (true, false, _) => {
                         run_test_file_with_postgres(
@@ -230,7 +247,7 @@ async fn run_tests() -> Result<()> {
                             filters.as_ref(),
                             currently_running_sql_tracker_clone,
                         )
-                        .await?
+                        .await
                     }
                     (true, true, _) => {
                         run_complete_file_with_postgres(
@@ -240,22 +257,35 @@ async fn run_tests() -> Result<()> {
                             m_style_clone,
                             currently_running_sql_tracker_clone,
                         )
-                        .await?
+                        .await
                     }
                 };
-                // Log slow files (>30s) for CI debugging
+
                 let elapsed = file_start.elapsed();
-                if elapsed.as_secs() > 30 {
+                if timing_debug_slow_files && elapsed.as_secs() > 30 {
                     eprintln!(
                         "Slow file: {} took {:.1}s",
                         relative_path_for_timing.display(),
                         elapsed.as_secs_f64()
                     );
                 }
-                Ok(())
+
+                (result, elapsed)
             })
             .join()
-            .map(move |result| (result, relative_path, 
currently_running_sql_tracker))
+            .map(move |result| {
+                let elapsed = match &result {
+                    Ok((_, elapsed)) => *elapsed,
+                    Err(_) => Duration::ZERO,
+                };
+
+                (
+                    result.map(|(thread_result, _)| thread_result),
+                    relative_path,
+                    currently_running_sql_tracker,
+                    elapsed,
+                )
+            })
         })
         // run up to num_cpus streams in parallel
         .buffer_unordered(options.test_threads)
@@ -274,10 +304,30 @@ async fn run_tests() -> Result<()> {
                 }
             }
         })
-        .flat_map(|(result, test_file_path, current_sql)| {
+        .collect()
+        .await;
+
+    let mut file_timings: Vec<FileTiming> = file_results
+        .iter()
+        .map(|(_, path, _, elapsed)| FileTiming {
+            relative_path: path.clone(),
+            elapsed: *elapsed,
+        })
+        .collect();
+
+    file_timings.sort_by(|a, b| {
+        b.elapsed
+            .cmp(&a.elapsed)
+            .then_with(|| a.relative_path.cmp(&b.relative_path))
+    });
+
+    print_timing_summary(&options, &m, is_ci, &file_timings)?;
+
+    let errors: Vec<_> = file_results
+        .into_iter()
+        .filter_map(|(result, test_file_path, current_sql, _)| {
             // Filter out any Ok() leaving only the DataFusionErrors
-            futures::stream::iter(match result {
-                // Tokio panic error
+            match result {
                 Err(e) => {
                     let error = DataFusionError::External(Box::new(e));
                     let current_sql = current_sql.get_currently_running_sqls();
@@ -307,10 +357,9 @@ async fn run_tests() -> Result<()> {
                     }
                 }
                 Ok(thread_result) => thread_result.err(),
-            })
+            }
         })
-        .collect()
-        .await;
+        .collect();
 
     m.println(format!(
         "Completed {} test files in {}",
@@ -332,6 +381,69 @@ async fn run_tests() -> Result<()> {
     }
 }
 
+fn print_timing_summary(
+    options: &Options,
+    progress: &MultiProgress,
+    is_ci: bool,
+    file_timings: &[FileTiming],
+) -> Result<()> {
+    let mode = options.timing_summary_mode(is_ci);
+    if mode == TimingSummaryMode::Off || file_timings.is_empty() {
+        return Ok(());
+    }
+
+    let top_n = options.timing_top_n;
+    debug_assert!(matches!(
+        mode,
+        TimingSummaryMode::Top | TimingSummaryMode::Full
+    ));
+    let count = if mode == TimingSummaryMode::Full {
+        file_timings.len()
+    } else {
+        top_n
+    };
+
+    progress.println("Per-file elapsed summary (deterministic):")?;
+    for (idx, timing) in file_timings.iter().take(count).enumerate() {
+        progress.println(format!(
+            "{:>3}. {:>8.3}s  {}",
+            idx + 1,
+            timing.elapsed.as_secs_f64(),
+            timing.relative_path.display()
+        ))?;
+    }
+
+    if mode != TimingSummaryMode::Full && file_timings.len() > count {
+        progress.println(format!(
+            "... {} more files omitted (use --timing-summary full to show 
all)",
+            file_timings.len() - count
+        ))?;
+    }
+
+    Ok(())
+}
+
+fn is_env_truthy(name: &str) -> bool {
+    std::env::var_os(name)
+        .and_then(|value| value.into_string().ok())
+        .is_some_and(|value| {
+            matches!(
+                value.trim().to_ascii_lowercase().as_str(),
+                "1" | "true" | "yes" | "on"
+            )
+        })
+}
+
+fn parse_timing_top_n(arg: &str) -> std::result::Result<usize, String> {
+    let parsed = arg
+        .parse::<usize>()
+        .map_err(|error| format!("invalid value '{arg}': {error}"))?;
+    if parsed == 0 {
+        return Err("must be >= 1".to_string());
+    }
+    Ok(parsed)
+}
+
 async fn run_test_file_substrait_round_trip(
     test_file: TestFile,
     validator: Validator,
@@ -825,6 +937,24 @@ struct Options {
     )]
     test_threads: usize,
 
+    #[clap(
+        long,
+        env = "SLT_TIMING_SUMMARY",
+        value_enum,
+        default_value_t = TimingSummaryMode::Auto,
+        help = "Per-file timing summary mode: auto|off|top|full"
+    )]
+    timing_summary: TimingSummaryMode,
+
+    #[clap(
+        long,
+        env = "SLT_TIMING_TOP_N",
+        default_value_t = 10,
+        value_parser = parse_timing_top_n,
+        help = "Number of files to show when timing summary mode is auto/top 
(must be >= 1)"
+    )]
+    timing_top_n: usize,
+
     #[clap(
         long,
         value_name = "MODE",
@@ -835,6 +965,19 @@ struct Options {
 }
 
 impl Options {
+    fn timing_summary_mode(&self, is_ci: bool) -> TimingSummaryMode {
+        match self.timing_summary {
+            TimingSummaryMode::Auto => {
+                if is_ci {
+                    TimingSummaryMode::Top
+                } else {
+                    TimingSummaryMode::Off
+                }
+            }
+            mode => mode,
+        }
+    }
+
     /// Because this test can be run as a cargo test, commands like
     ///
     /// ```shell
@@ -886,7 +1029,7 @@ impl Options {
             ColorChoice::Never => false,
             ColorChoice::Auto => {
                 // CARGO_TERM_COLOR takes precedence over auto-detection
-                let cargo_term_color = ColorChoice::from_str(
+                let cargo_term_color = <ColorChoice as FromStr>::from_str(
                     &std::env::var("CARGO_TERM_COLOR")
                         .unwrap_or_else(|_| "auto".to_string()),
                 )


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

Reply via email to