From 66ebab8cfe5a78691639382222ff4c992a3f8c3c Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Tue, 16 Jun 2026 01:03:37 +0200 Subject: [PATCH 01/16] feat(report): finalize HTML report, YAML output, and historical comparison --- .agent/README.md | 2 +- CURRENT_VERSION.txt | 2 +- Changelog | 11 ++ POTENTIAL_ISSUES.md | 7 +- ROADMAP.md | 44 +++-- USAGE.md | 5 +- build/check_compliance.pl | 3 +- .../warning_elimination_version_cache.md | 26 +++ mysqltuner.pl | 183 ++++++++++++++---- releases/v2.9.0.md | 112 +++++++++++ tests/unit_versions.t | 104 +++++++++- 11 files changed, 423 insertions(+), 76 deletions(-) create mode 100644 documentation/specifications/warning_elimination_version_cache.md create mode 100644 releases/v2.9.0.md diff --git a/.agent/README.md b/.agent/README.md index 758b3dc00..5d39860b6 100644 --- a/.agent/README.md +++ b/.agent/README.md @@ -48,4 +48,4 @@ This directory contains the project's technical constitution, specialized skills --- -*Generated automatically by `/doc-sync` on 2026-06-12 20:03:20* \ No newline at end of file +*Generated automatically by `/doc-sync` on 2026-06-16 00:57:53* \ No newline at end of file diff --git a/CURRENT_VERSION.txt b/CURRENT_VERSION.txt index b33624976..c8e38b614 100644 --- a/CURRENT_VERSION.txt +++ b/CURRENT_VERSION.txt @@ -1 +1 @@ -2.8.45 +2.9.0 diff --git a/Changelog b/Changelog index b226931eb..4d8017646 100644 --- a/Changelog +++ b/Changelog @@ -1,5 +1,16 @@ # MySQLTuner Changelog +2.9.0 2026-06-15 + +- chore(main): whitelist deps and system commit scopes in check_compliance.pl to support Dependabot and host metrics commits. +- feat(cli): create an agent-ready output format (JSON/YAML) so that MySQLTuner can be easily integrated by AI agents. +- feat(report): finalize a complete HTML report file beginning in v2.8.45. +- feat(report): support historical comparison of database diagnostics and performance metrics over time. +- fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. +- fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables. +- fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks. +- test(versions): add unit tests for version caching and comparisons in tests/unit_versions.t. + 2.8.45 2026-06-04 - chore: restore doc_sync.py utility script and run doc synchronization. diff --git a/POTENTIAL_ISSUES.md b/POTENTIAL_ISSUES.md index cfc248b21..728544e36 100644 --- a/POTENTIAL_ISSUES.md +++ b/POTENTIAL_ISSUES.md @@ -2,13 +2,13 @@ This file records anomalies discovered during laboratory testing (Perl warnings, SQL errors, etc.). -## [2026-05-29 Audit] Status Refresh v2.8.44 +## [2026-06-16 Audit] Status Refresh v2.9.0 ### Unit Test Results - **Status**: ✅ ALL PASS -- **Files**: 72 test files -- **Assertions**: 362 tests +- **Files**: 81 test files +- **Assertions**: 462 tests - **Perl Syntax**: Clean (`perl -cw mysqltuner.pl` — no warnings) ### Test Coverage Analysis @@ -70,6 +70,7 @@ This file records anomalies discovered during laboratory testing (Perl warnings, - **Source**: Each call to `mysql_version_ge()`, `mysql_version_le()`, `mysql_version_eq()` re-parses `$myvar{'version'}` via regex - **Impact**: Redundant computation — called 100+ times across the script - **Severity**: 🟢 LOW — performance impact minimal but code duplication +- **Status**: [x] **FIXED** — Implemented version parsing caching via `_parse_version()`. #### PI-009: MariaDB 10.6 Approaching EOL - **Source**: [mariadb_support.md](file:///mariadb_support.md) diff --git a/ROADMAP.md b/ROADMAP.md index 1961170a4..6422fe775 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,15 +48,15 @@ To ensure consistency and high-density development, the following roles are defi * [x] **Multi-Cloud Autodiscovery**: Automated detection of RDS, GCP, and Azure specific performance flags and optimizations. * [x] **Query Anti-Pattern Detection**: Use `performance_schema` to identify non-SARGable queries and `SELECT *` abuse. -### [Phase 4: Advanced Intelligence & Ecosystem](file:///documentation/specifications/roadmap_phase_iv_intelligence.md) [IN PROGRESS] +### [Phase 4: Advanced Intelligence & Ecosystem](file:///documentation/specifications/roadmap_phase_iv_intelligence.md) [COMPLETED] -* [/] **Smart Migration LTS Advisor**: +* [x] **Smart Migration LTS Advisor**: * [x] Automated pre-upgrade risk reports (variable removal, deprecation notices). - * [ ] Compatibility audit for SQL modes, character sets, and version-specific engine changes. + * [x] Compatibility audit for SQL modes, character sets, and version-specific engine changes. * [x] **Weighted Health Score**: * [x] Unified KPI (0-100) aggregating findings from Security, Performance, and Resilience. - * [ ] Comparative scoring against previous runs or established industry baselines. -* [/] **Predictive Capacity Planning**: + * [x] Comparative scoring against previous runs or established industry baselines. +* [x] **Predictive Capacity Planning**: * [x] Data growth forecasting based on binlog throughput and table statistics. * [x] Memory headroom analysis for traffic peak forecasting. * [x] AUTO_INCREMENT capacity near max value detection. @@ -68,35 +68,37 @@ To ensure consistency and high-density development, the following roles are defi * [x] Implemented advanced dominant style detection and deviations audit for tables, views, indexes, and columns. * [x] **CSV Export Enhancements**: * [x] Export naming convention deviations (tables, views, indexes, columns), primary key naming/surrogate key issues, missing foreign keys, JSON columns without virtual columns, and insecure authentication plugins to separate CSV files. -* [/] **Security Hardening 2.0**: - * [ ] Version-based CVE exposure detection (community-fed database). +* [x] **Security Hardening 2.0**: + * [x] Version-based CVE exposure detection (community-fed database). * [x] Advanced encryption-at-rest (TDE) and SSL/TLS cipher suite validation. * [x] **Extended Authentication Plugins Audit**: Verify password hashing methods against the extended plugins support matrix (including `mysql_native_password`, `mysql_old_password`, `sha256_password`, `caching_sha2_password`, `unix_socket`, `ed25519`, and the new MariaDB `parsec` plugin). See [AUTHENTICATION_PLUGINS.md](file:///documentation/AUTHENTICATION_PLUGINS.md). -* [/] **Guided Auto-Fix Engine**: - * [ ] Interactive mode to simulate configuration changes. +* [x] **Guided Auto-Fix Engine**: + * [x] Interactive mode to simulate configuration changes. * [x] Generation of ready-to-use `SET GLOBAL` or `my.cnf` snippets. * [x] **Modular Reporting Engine**: Re-implemented native HTML report generation (--reportfile) using built-in layout, removing external template engine dependencies. -* [/] **Historical Trend Analysis**: (Experimental) Allow the script to ingest previous run data to identify performance regressions. +* [x] **Complete HTML Report Finalization**: Finalize a complete HTML report file beginning in v2.8.45. +* [x] **Historical Trend & Comparison Analysis**: Support historical comparison of database diagnostics and performance metrics over time. +* [x] **Agent-Ready Output**: Create an agent-ready output format (JSON/YAML) so that MySQLTuner can be easily integrated and used by AI agents. --- -### Phase 5: Code Quality & Regression Hardening [NEW — PRIORITY] +### Phase 5: Code Quality & Regression Hardening [COMPLETED] > Derived from the test campaign analysis on v2.8.43. Addresses critical code quality issues identified during the 5-iteration test audit. -* [ ] **Perl Warning Elimination**: - * [ ] Add definedness guards to `mysql_version_ge()`, `mysql_version_le()`, `mysql_version_eq()` to prevent 74 uninitialized value warnings. - * [ ] Guard `$mycalc{'innodb_log_size_pct'}` and `$myvar{'innodb_log_file_size'}` before use in InnoDB analysis. - * [ ] Guard `$myvar{'version_comment'}` in MariaDB detection path. +* [x] **Perl Warning Elimination**: + * [x] Add definedness guards to `mysql_version_ge()`, `mysql_version_le()`, `mysql_version_eq()` to prevent 74 uninitialized value warnings. + * [x] Guard `$mycalc{'innodb_log_size_pct'}` and `$myvar{'innodb_log_file_size'}` before use in InnoDB analysis. + * [x] Guard `$myvar{'version_comment'}` in MariaDB detection path. * [x] **Version Validation Updates**: * [x] Add MySQL 9.6 to `validate_mysql_version()` supported LTS list. * [x] Remove MySQL 9.5 (now Outdated) from the LTS list. -* [ ] **Test Coverage Expansion**: - * [ ] Achieve ≥80% subroutine test coverage (currently ~55%, 74 of 165 uncovered). - * [ ] Priority coverage: `check_architecture`, `system_recommendations`, `mysql_indexes`, `mysql_views`, `mysql_routines`, `mysql_triggers`, `make_recommendations`. - * [ ] Add tests for `dump_result`, `close_outputfile`, `get_template_model`. -* [ ] **Version Comparison Optimization**: - * [ ] Cache parsed version components instead of re-parsing `$myvar{'version'}` on every call to `mysql_version_ge/le/eq`. +* [x] **Test Coverage Expansion**: + * [x] Achieve ≥80% subroutine test coverage (reached ~92%, only 13 of 167 system/IO-heavy subroutines uncovered). + * [x] Priority coverage: `check_architecture`, `system_recommendations`, `mysql_indexes`, `mysql_views`, `mysql_routines`, `mysql_triggers`, `make_recommendations`. + * [x] Add tests for `dump_result` and `close_outputfile` (`get_template_model` obsoleted and removed). +* [x] **Version Comparison Optimization**: + * [x] Cache parsed version components instead of re-parsing `$myvar{'version'}` on every call to `mysql_version_ge/le/eq`. --- diff --git a/USAGE.md b/USAGE.md index b25e01b5f..010c04d38 100644 --- a/USAGE.md +++ b/USAGE.md @@ -1,6 +1,6 @@ # NAME - MySQLTuner 2.8.45 - MySQL High Performance Tuning Script + MySQLTuner 2.9.0 - MySQL High Performance Tuning Script # IMPORTANT USAGE GUIDELINES @@ -15,7 +15,7 @@ See `mysqltuner --help` for a full list of available options and their categorie # VERSION -Version 2.8.45 +Version 2.9.0 =head1 PERLDOC You can find documentation for this module with the perldoc command. @@ -72,6 +72,7 @@ Jean-Marie Renouard - jmrenouard@gmail.com - Stephan GroBberndt - Christian Loos - Long Radix +- derZ-dev # SUPPORT diff --git a/build/check_compliance.pl b/build/check_compliance.pl index 8a09d9c00..c17daec0f 100755 --- a/build/check_compliance.pl +++ b/build/check_compliance.pl @@ -139,7 +139,8 @@ 'versions', 'report', 'security', 'cve', 'options', 'lab', 'container', 'refactor', 'style', 'releases', 'dependencies', 'cli', - 'auth', 'main', 'metadata' + 'auth', 'main', 'metadata', 'deps', + 'system' ); # Lint Changelog structure and scopes for the current version block diff --git a/documentation/specifications/warning_elimination_version_cache.md b/documentation/specifications/warning_elimination_version_cache.md new file mode 100644 index 000000000..b32c5dead --- /dev/null +++ b/documentation/specifications/warning_elimination_version_cache.md @@ -0,0 +1,26 @@ +--- +test_file: tests/unit_versions.t +--- +# Specification: Warning Elimination & Version Comparison Optimization + +## Goal +Eliminate Perl runtime uninitialized value warnings during execution (specifically in version checks, InnoDB log analysis, and MariaDB detection paths) and optimize the version comparison routines by caching parsed version components. + +## Requirements +1. **Version Caching & Optimization**: + - Cache the parsed version components (major, minor, micro) in lexical variables `$cached_v_maj`, `$cached_v_min`, `$cached_v_mic` based on `$myvar{'version'}`. + - Refactor `mysql_version_ge()`, `mysql_version_le()`, and `mysql_version_eq()` to use cached version components instead of performing regex parsing on every invocation. + - Guard comparison helper parameters (`$maj`, `$min`, `$mic`) to prevent uninitialized value warnings when comparison arguments are undefined. + - Guard `$myvar{'version'}` regex match inside `validate_mysql_version()` to avoid matching on undefined variables. + +2. **InnoDB Analysis Guards**: + - Guard `$mycalc{'innodb_log_size_pct'}` and `$myvar{'innodb_log_file_size'}` with definedness operators (`// 0`) before using them in calculations or printing output within `mysql_innodb()`. + - Prevent potential uninitialized warnings during multiplication or printing in the InnoDB log size checks block. + +3. **MariaDB Detection Guards**: + - Guard `$myvar{'version_comment'}` and `$myvar{'version'}` checks using the definedness default operator (`// ''`) inside the parallel replication and query cache plugin check blocks. + - Guard the `infoprint` statement in `security_recommendations()` where version and version comment are printed. + +## Verification +- Run `make unit-tests` to ensure that all unit tests execute and pass cleanly. +- The test output audit gate must report 0 runtime uninitialized value warnings from the version checks or InnoDB code path. diff --git a/mysqltuner.pl b/mysqltuner.pl index 3c9d77595..4a18e3e42 100755 --- a/mysqltuner.pl +++ b/mysqltuner.pl @@ -1,5 +1,5 @@ #!/usr/bin/env perl -# mysqltuner.pl - Version 2.8.45 +# mysqltuner.pl - Version 2.9.0 # High Performance MySQL Tuning Script # Copyright (C) 2015-2026 Jean-Marie Renouard - jmrenouard@gmail.com # Copyright (C) 2006-2026 Major Hayden - major@mhtx.net @@ -67,7 +67,7 @@ package main; our $is_win = $^O eq 'MSWin32'; # Set up a few variables for use in the script -our $tunerversion = "2.8.45"; +our $tunerversion = "2.9.0"; our ( @adjvars, @generalrec, @modeling, @sysrec, @secrec ); our ( %result, %myvar, %real_vars, %mystat, %mycalc, %myrepl, %myreplicas, $dummyselect ); @@ -300,6 +300,12 @@ package main; desc => 'Print result as JSON formatted string', cat => 'PERFORMANCE' }, + 'yaml' => { + type => '!', + default => 0, + desc => 'Print result as YAML string', + cat => 'PERFORMANCE' + }, 'dumpdir' => { type => '=s', default => undef, @@ -867,7 +873,8 @@ sub show_help { } sub prettyprint { - print $_[0] . "\n" unless ( $opt{'silent'} or $opt{'json'} ); + print $_[0] . "\n" + unless ( $opt{'silent'} or $opt{'json'} or $opt{'yaml'} ); print $fh $_[0] . "\n" if defined($fh); my $plain_text = $_[0] // ''; $plain_text =~ s/\e\[[0-9;]*[mK]//g; @@ -1390,7 +1397,7 @@ sub check_security_2_0 { } sub generate_auto_fix_snippets { - return if $opt{'silent'} || $opt{'json'}; + return if $opt{'silent'} || $opt{'json'} || $opt{'yaml'}; subheaderprint "Guided Auto-Fix Snippets"; if ( @adjvars > 0 ) { @@ -1996,7 +2003,7 @@ sub get_http_cli { # Checks for updates to MySQLTuner sub validate_tuner_version { if ( $opt{'checkversion'} eq 0 ) { - print "\n" unless ( $opt{'silent'} or $opt{'json'} ); + print "\n" unless ( $opt{'silent'} or $opt{'json'} or $opt{'yaml'} ); infoprint "Skipped version check for MySQLTuner script"; return; } @@ -2058,7 +2065,7 @@ sub validate_tuner_version { sub update_tuner_version { if ( $opt{'updateversion'} eq 0 ) { badprint "Skipped version update for MySQLTuner script"; - print "\n" unless ( $opt{'silent'} or $opt{'json'} ); + print "\n" unless ( $opt{'silent'} or $opt{'json'} or $opt{'yaml'} ); return; } @@ -2960,7 +2967,7 @@ sub write_manifest_files { } my $json_content = - "{\n \"version\": \"" . ( $tunerversion // '2.8.45' ) . "\",\n"; + "{\n \"version\": \"" . ( $tunerversion // '2.9.0' ) . "\",\n"; $json_content .= " \"exported_at\": \"" . scalar( gmtime() ) . " UTC\",\n"; $json_content .= " \"total_files\": $total_files,\n"; $json_content .= " \"total_size_bytes\": $total_size,\n"; @@ -2976,7 +2983,7 @@ sub write_manifest_files { my $meta_content = "MySQLTuner Offline Diagnostic Snapshot Metadata\n"; $meta_content .= "================================================\n"; - $meta_content .= "Version: " . ( $tunerversion // '2.8.45' ) . "\n"; + $meta_content .= "Version: " . ( $tunerversion // '2.9.0' ) . "\n"; $meta_content .= "Exported At: " . scalar( gmtime() ) . " UTC\n"; $meta_content .= "Host: " . ( $myvar{'hostname'} // 'unknown' ) . "\n"; $meta_content .= @@ -5016,7 +5023,8 @@ sub check_auth_plugins { sub security_recommendations { subheaderprint "Security Recommendations"; - infoprint "$myvar{'version_comment'} - $myvar{'version'}"; + infoprint( ( $myvar{'version_comment'} // 'N/A' ) . " - " + . ( $myvar{'version'} // 'N/A' ) ); my $PASS_COLUMN_NAME = get_password_column_name(); @@ -5508,8 +5516,8 @@ sub get_replication_status { } # Parallel replication checks (MariaDB specific) - if ( ( $myvar{'version'} =~ /MariaDB/i ) - or ( $myvar{'version_comment'} =~ /MariaDB/i ) ) + if ( ( ( $myvar{'version'} // '' ) =~ /MariaDB/i ) + or ( ( $myvar{'version_comment'} // '' ) =~ /MariaDB/i ) ) { my $parallel_threads = $myvar{'slave_parallel_threads'} // $myvar{'replica_parallel_threads'} // 0; @@ -5580,15 +5588,33 @@ sub validate_mysql_version { } } +my $cached_version_str; +my ( $cached_v_maj, $cached_v_min, $cached_v_mic ); + +sub _parse_version { + my $ver = $myvar{'version'} // ''; + if ( !defined $cached_version_str || $cached_version_str ne $ver ) { + $cached_version_str = $ver; + if ( $ver =~ /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/ ) { + $cached_v_maj = $1 // 0; + $cached_v_min = $2 // 0; + $cached_v_mic = $3 // 0; + } + else { + $cached_v_maj = 0; + $cached_v_min = 0; + $cached_v_mic = 0; + } + } + return ( $cached_v_maj, $cached_v_min, $cached_v_mic ); +} + # Checks if MySQL version is equal to (major, minor, micro) sub mysql_version_eq { my ( $maj, $min, $mic ) = @_; return 0 unless defined $myvar{'version'}; - my ( $v_maj, $v_min, $v_mic ) = - $myvar{'version'} =~ /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/; - $v_maj //= 0; - $v_min //= 0; - $v_mic //= 0; + $maj //= 0; + my ( $v_maj, $v_min, $v_mic ) = _parse_version(); return int($v_maj) == int($maj) if ( !defined($min) && !defined($mic) ); @@ -5603,13 +5629,10 @@ sub mysql_version_eq { sub mysql_version_ge { my ( $maj, $min, $mic ) = @_; return 0 unless defined $myvar{'version'}; - $min ||= 0; - $mic ||= 0; - my ( $v_maj, $v_min, $v_mic ) = - $myvar{'version'} =~ /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/; - $v_maj //= 0; - $v_min //= 0; - $v_mic //= 0; + $maj //= 0; + $min //= 0; + $mic //= 0; + my ( $v_maj, $v_min, $v_mic ) = _parse_version(); return int($v_maj) > int($maj) @@ -5623,13 +5646,10 @@ sub mysql_version_ge { sub mysql_version_le { my ( $maj, $min, $mic ) = @_; return 0 unless defined $myvar{'version'}; - $min ||= 0; - $mic ||= 0; - my ( $v_maj, $v_min, $v_mic ) = - $myvar{'version'} =~ /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/; - $v_maj //= 0; - $v_min //= 0; - $v_mic //= 0; + $maj //= 0; + $min //= 0; + $mic //= 0; + my ( $v_maj, $v_min, $v_mic ) = _parse_version(); return int($v_maj) < int($maj) @@ -10503,20 +10523,22 @@ sub mysql_innodb { infoprint " +-- InnoDB Log File Size: " . hr_bytes( $myvar{'innodb_log_file_size'} ); } - if ( defined $myvar{'innodb_log_files_in_group'} ) { + if ( defined $myvar{'innodb_log_files_in_group'} + && defined $myvar{'innodb_log_file_size'} ) + { infoprint " +-- InnoDB Log File In Group: " . $myvar{'innodb_log_files_in_group'}; infoprint " +-- InnoDB Total Log File Size: " . hr_bytes( $myvar{'innodb_log_files_in_group'} * $myvar{'innodb_log_file_size'} ) . "(" - . $mycalc{'innodb_log_size_pct'} + . ( $mycalc{'innodb_log_size_pct'} // 0 ) . " % of buffer pool)"; } - else { + elsif ( defined $myvar{'innodb_log_file_size'} ) { infoprint " +-- InnoDB Total Log File Size: " . hr_bytes( $myvar{'innodb_log_file_size'} ) . "(" - . $mycalc{'innodb_log_size_pct'} + . ( $mycalc{'innodb_log_size_pct'} // 0 ) . " % of buffer pool)"; } } @@ -11509,6 +11531,14 @@ sub historical_comparison { ); } + # Compare Health Score + if ( defined $result{'HealthScore'} && defined $old->{'HealthScore'} ) { + my $diff = $result{'HealthScore'} - $old->{'HealthScore'}; + my $trend = ( $diff > 0 ) ? "+" : ""; + infoprint sprintf( "Health Score Trend: %d -> %d (%s%d)", + $old->{'HealthScore'}, $result{'HealthScore'}, $trend, $diff ); + } + # 2. Compare Total Data Size if ( defined $result{'Stats'}{'Total Data Size'} && defined $old->{'Stats'}{'Total Data Size'} ) @@ -11654,8 +11684,8 @@ sub check_query_anti_patterns { sub mariadb_query_cache_info { subheaderprint "Query Cache Information"; - unless ( ( $myvar{'version'} =~ /MariaDB/i ) - or ( $myvar{'version_comment'} =~ /MariaDB/i ) ) + unless ( ( ( $myvar{'version'} // '' ) =~ /MariaDB/i ) + or ( ( $myvar{'version_comment'} // '' ) =~ /MariaDB/i ) ) { infoprint "Not a MariaDB server. Skipping Query Cache Info plugin check."; @@ -11821,7 +11851,7 @@ sub mysql_databases { $result{'Databases'}{'All databases'}{'Index Pct'} = percentage( $totaldbinfo[2], $totaldbinfo[3] ) . "%"; $result{'Databases'}{'All databases'}{'Total Size'} = $totaldbinfo[3]; - print "\n" unless ( $opt{'silent'} or $opt{'json'} ); + print "\n" unless ( $opt{'silent'} or $opt{'json'} or $opt{'yaml'} ); my $nbViews = 0; my $nbTables = 0; @@ -12494,6 +12524,63 @@ sub format_recommendation_item { return $item // ''; } +sub _to_yaml { + my ( $data, $indent ) = @_; + $indent //= 0; + my $spaces = ' ' x $indent; + my $output = ''; + + if ( !defined $data ) { + return "~\n"; + } + elsif ( ref $data eq 'HASH' ) { + $output .= "\n" if $indent > 0; + foreach my $key ( sort keys %$data ) { + my $val = $data->{$key}; + $output .= $spaces . $key . ":"; + if ( ref $val ) { + $output .= _to_yaml( $val, $indent + 1 ); + } + else { + my $v = $val // ''; + if ( $v =~ /[:\#\n\'\"]/ or $v eq '' ) { + $v =~ s/'/''/g; + $v = "'$v'"; + } + $output .= " $v\n"; + } + } + } + elsif ( ref $data eq 'ARRAY' ) { + $output .= "\n" if $indent > 0; + foreach my $item (@$data) { + $output .= $spaces . "-"; + if ( ref $item ) { + my $inner = _to_yaml( $item, $indent + 1 ); + $inner =~ s/^\n\s*//; + $output .= " " . $inner; + } + else { + my $v = $item // ''; + if ( $v =~ /[:\#\n\'\"]/ or $v eq '' ) { + $v =~ s/'/''/g; + $v = "'$v'"; + } + $output .= " $v\n"; + } + } + } + else { + my $v = $data; + if ( $v =~ /[:\#\n\'\"]/ or $v eq '' ) { + $v =~ s/'/''/g; + $v = "'$v'"; + } + $output .= "$v\n"; + } + return $output; +} + sub dump_result { #debugprint Dumper( \%result ) if ( $opt{'debug'} ); @@ -12838,6 +12925,20 @@ sub dump_result { close $fh; } } + + if ( $opt{'yaml'} ) { + my $yaml_str = _to_yaml( \%result ); + print $yaml_str; + + if ( $opt{'outputfile'} ) { + unlink $opt{'outputfile'} if ( -e $opt{'outputfile'} ); + open my $fh, q(>), $opt{'outputfile'} + or die +"Unable to open $opt{'outputfile'} in write mode. please check permissions for this file or directory"; + print $fh $yaml_str; + close $fh; + } + } } sub which { @@ -13153,7 +13254,7 @@ sub dump_csv_files { =head1 NAME - MySQLTuner 2.8.45 - MySQL High Performance Tuning Script + MySQLTuner 2.9.0 - MySQL High Performance Tuning Script =head1 IMPORTANT USAGE GUIDELINES @@ -13168,7 +13269,7 @@ =head1 OPTIONS =head1 VERSION -Version 2.8.45 +Version 2.9.0 =head1 PERLDOC You can find documentation for this module with the perldoc command. @@ -13338,6 +13439,10 @@ =head1 CONTRIBUTORS Long Radix +=item * + +derZ-dev + =back =head1 SUPPORT diff --git a/releases/v2.9.0.md b/releases/v2.9.0.md new file mode 100644 index 000000000..80e044d99 --- /dev/null +++ b/releases/v2.9.0.md @@ -0,0 +1,112 @@ +# Release Notes - v2.9.0 + +**Date**: 2026-06-15 + +## 📝 Executive Summary + +```text +2.9.0 2026-06-15 + +- chore(main): whitelist deps and system commit scopes in check_compliance.pl to support Dependabot and host metrics commits. +- feat(cli): create an agent-ready output format (JSON/YAML) so that MySQLTuner can be easily integrated by AI agents. +- feat(report): finalize a complete HTML report file beginning in v2.8.45. +- feat(report): support historical comparison of database diagnostics and performance metrics over time. +- fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. +- fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables. +- fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks. +- test(versions): add unit tests for version caching and comparisons in tests/unit_versions.t. +``` + +## 📈 Diagnostic Growth Indicators + +| Metric | Current | Progress | Status | +| :--- | :--- | :--- | :--- | +| Total Indicators | 15 | 0 | 🛡️ | +| Efficiency Checks | 0 | 0 | 🛡️ | +| Risk Detections | 2 | 0 | 🛡️ | +| Information Points | 13 | 0 | 🛡️ | + +## 🛠️ Internal Commit History + +- Merge pull request #919 from major/renovate/peter-evans-create-pull-request-8.x (289d87f) +- chore(deps): update peter-evans/create-pull-request action to v8 (9ba4831) +- Merge pull request #924 from major/renovate/pin-dependencies (1f0200e) +- chore(deps): pin dependencies (41951bd) +- chore: automated project metadata update (a325db3) +- Merge pull request #920 from jmrenouard/2.8.45 (b049d0f) +- feat(system): display host/RAM recap in remote/cloud mode & server uptime in all configurations (dec2084) +- Merge remote-tracking branch 'origin/master' into 2.8.45 (0c36d11) +- chore(deps): lock file maintenance (#925) (f5a58bb) +- chore: automated project metadata update (941d960) +- Merge pull request #48 from derZ-dev/mariadb_12_3_lts (d437355) +- Added support for MariaDB 12.3 (latest LTS version) (3e901a4) +- docs: regenerate release notes (2c9a4e4) +- perf: optimize column exploration in mysql_tables (0e34fe9) +- chore: update documentation sync timestamp and execution logs (232918d) +- docs(metadata): update automatically generated README timestamp (24224ea) +- docs: regenerate release notes (34b3229) +- feat(cli): export both complete and filtered CSV files for sys views (ebd5409) +- test(lab): create tests/issue_923.t and restore tests/issue_864.t (be4bd30) +- chore: update execution logs and sync documentation timestamp (cab32f1) +- chore: automated project metadata update (5f0ae0d) +- Merge pull request #922 from stonio/patch-1 (07e8c85) +- fix(test): only verify release tag consistency if ref is a tag in check_release_files.sh (a0a3341) +- docs(metadata): update automatically generated README timestamp (abe1eef) +- docs: regenerate release notes (748bb03) +- feat(cli): bypass temptable_max_ram on MEMORY/MariaDB and verify temptable_max_mmap (94a19ee) +- Fix regression in Dockerfile entrypoint (5aec18d) +- docs: generate USAGE.md (8d0ddeb) +- chore: release v2.8.45 (0347fad) +- chore(deps): update all non-major dependencies to v21.0.2 (#918) (188a0f1) +- Merge pull request #917 from jmrenouard/master (37ddaeb) +- chore: automated project metadata update (40a279a) +- Merge pull request #916 from jmrenouard/v2.8.44 (0f24670) +- chore: automated project metadata update (c4c118a) +- Merge pull request #47 from jmrenouard/v2.8.44 (2c8ee3c) +- fix(security): patch tmp path traversal and brace-expansion DoS vulnerabilities (aa90853) +- feat(test): boost subroutine coverage to 92% and fix 3 Perl bugs (34f8e8a) +- docs: add Docker install instructions and releases location in all READMEs (e1b77ed) +- docs: regenerate release notes (849e4d2) +- feat(test): clean unit tests output and fix mock compilation warnings (35ecd52) +- chore(ci): ignore execution.log in gitignore (a9ca211) +- docs: regenerate release notes (acd852c) +- fix(cli): resolve false positive AUTO_INCREMENT near max capacity warning (#913) (39d96eb) +- chore(ci): update execution log with generate_usage outputs (1c60bff) +- feat(ci): implement version dry-run validation, update LTS support tests, and update execution log (a78c747) +- feat(ci): implement EOL sync, version dry-run validation, and git hooks (b59a2f7) +- docs: generate USAGE.md (225e3e4) +- feat(versions): update validate_mysql_version with 9.6 support and remove 9.5 (d2ca4d6) +- feat(report): re-implement HTML report and cleanup templates (d3b6d3c) + +## ⚙️ Technical Evolutions + +### ➕ CLI Options Added +- `--innodb_buffer_stats_by_schema` +- `--innodb_buffer_stats_by_table` +- `--processlist` +- `--schema_auto_increment_columns` +- `--schema_index_statistics` +- `--schema_object_overview` +- `--schema_redundant_indexes` +- `--schema_table_lock_waits` +- `--schema_table_statistics` +- `--schema_table_statistics_with_buffer` +- `--schema_tables_with_full_table_scans` +- `--schema_unused_indexes` +- `--session` +- `--statement_analysis` +- `--statements_with_errors_or_warnings` +- `--statements_with_full_table_scans` +- `--statements_with_runtimes_in_95th_percentile` +- `--statements_with_sorting` +- `--statements_with_temp_tables` + +### ➖ CLI Options Deprecated +- `--data` +- `--template` + +## ✅ Laboratory Verification Results + +- [x] Automated TDD suite passed. +- [x] Multi-DB version laboratory execution validated. +- [x] Performance indicator delta analysis completed. diff --git a/tests/unit_versions.t b/tests/unit_versions.t index 64fec04ad..5d1953021 100644 --- a/tests/unit_versions.t +++ b/tests/unit_versions.t @@ -11,24 +11,112 @@ use Cwd 'abs_path'; $SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; # Load mysqltuner.pl as a library -my $script_dir = dirname(abs_path(__FILE__)); -my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +my $script_dir = dirname( abs_path(__FILE__) ); +my $script = + abs_path( File::Spec->catfile( $script_dir, '..', 'mysqltuner.pl' ) ); require $script; # 1. Test compare_tuner_version # This function is not pure, it uses global $tunerversion and prints. subtest 'compare_tuner_version' => sub { no warnings 'redefine'; - local *main::goodprint = sub { }; - local *main::badprint = sub { }; + local *main::goodprint = sub { }; + local *main::badprint = sub { }; local *main::update_tuner_version = sub { }; - + $main::tunerversion = "2.8.33"; - + # It returns undef, so we just check if it runs without crashing for now # or check the behavior if we mocked the prints to capture output. - ok(defined eval { main::compare_tuner_version("2.8.33"); 1 }, "Runs with same version"); - ok(defined eval { main::compare_tuner_version("2.9.0"); 1 }, "Runs with newer version"); + ok( defined eval { main::compare_tuner_version("2.8.33"); 1 }, + "Runs with same version" ); + ok( defined eval { main::compare_tuner_version("2.9.0"); 1 }, + "Runs with newer version" ); +}; + +subtest 'mysql_version comparisons with cache' => sub { + $main::myvar{'version'} = '8.0.33'; + ok( main::mysql_version_eq( 8, 0, 33 ), '8.0.33 eq 8.0.33' ); + ok( main::mysql_version_eq( 8, 0 ), '8.0.33 eq 8.0' ); + ok( main::mysql_version_eq(8), '8.0.33 eq 8' ); + ok( !main::mysql_version_eq( 5, 7 ), '8.0.33 not eq 5.7' ); + + ok( main::mysql_version_ge( 8, 0, 30 ), '8.0.33 ge 8.0.30' ); + ok( main::mysql_version_ge( 8, 0 ), '8.0.33 ge 8.0' ); + ok( !main::mysql_version_ge( 9, 0 ), '8.0.33 not ge 9.0' ); + + ok( main::mysql_version_le( 8, 0, 35 ), '8.0.33 le 8.0.35' ); + ok( main::mysql_version_le( 9, 0 ), '8.0.33 le 9.0' ); + ok( !main::mysql_version_le( 5, 7 ), '8.0.33 not le 5.7' ); +}; + +subtest '_to_yaml serialization' => sub { + my $data = { + name => 'MySQLTuner', + version => '2.9.0', + special => '2:9#0', + active => 1, + details => { + perf_bp => 10, + empty_val => '', + }, + list => [ 'a', 'b', { k => 'v' } ], + }; + + my $yaml = main::_to_yaml($data); + + # Check that strings are generated correctly + like( $yaml, qr/name: MySQLTuner/, 'Plain scalar serialized correctly' ); + like( $yaml, qr/version: 2\.9\.0/, 'Unquoted scalar serialized correctly' ); + like( + $yaml, + qr/special: '2:9#0'/, + 'Quoted scalar (with special chars) serialized correctly' + ); + like( $yaml, qr/active: 1/, 'Number serialized correctly' ); + like( $yaml, qr/empty_val: ''/, 'Empty string serialized correctly' ); + like( $yaml, qr/perf_bp: 10/, 'Nested hash scalar serialized correctly' ); + like( $yaml, qr/list:/, 'Array prefix serialized correctly' ); + like( $yaml, qr/-\s+a/, 'Array items serialized correctly' ); + like( $yaml, qr/-\s+k:\s+v/, + 'Array with nested hash serialized correctly' ); +}; + +subtest 'historical_comparison health score' => sub { + no warnings 'redefine'; + my $captured_trend; + local *main::infoprint = sub { + my $msg = shift; + if ( $msg =~ /Health Score Trend/ ) { + $captured_trend = $msg; + } + }; + local *main::badprint = sub { }; + + # Prepare current results + $main::result{'HealthScore'} = 85; + + # Mocking reading from file + my $compare_json = +'{"General":{"Date":"2026-06-15"},"HealthScore":70,"Stats":{"QPS":1.5,"Total Data Size":1000}}'; + + my $temp_file = File::Spec->catfile( $script_dir, 'temp_compare.json' ); + open my $tfh, '>', $temp_file or die $!; + print $tfh $compare_json; + close $tfh; + + local $main::opt{'compare-file'} = $temp_file; + main::historical_comparison(); + + unlink $temp_file; + + ok( defined $captured_trend, + "Health score trend comparison was triggered" ); + like( + $captured_trend, + qr/70 -> 85 \(\+15\)/, + "Trend correctly shows +15 improvement" + ); }; done_testing(); From 2a9b66e37b176be449f11cac8b1b86de6cf1e54d Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Tue, 16 Jun 2026 01:15:38 +0200 Subject: [PATCH 02/16] chore: remove execution.log from git repository and sync docs --- .agent/README.md | 2 +- execution.log | 56 ------------------------------------------------ 2 files changed, 1 insertion(+), 57 deletions(-) delete mode 100644 execution.log diff --git a/.agent/README.md b/.agent/README.md index 5d39860b6..06b3cd57c 100644 --- a/.agent/README.md +++ b/.agent/README.md @@ -48,4 +48,4 @@ This directory contains the project's technical constitution, specialized skills --- -*Generated automatically by `/doc-sync` on 2026-06-16 00:57:53* \ No newline at end of file +*Generated automatically by `/doc-sync`* \ No newline at end of file diff --git a/execution.log b/execution.log deleted file mode 100644 index 35fa007f1..000000000 --- a/execution.log +++ /dev/null @@ -1,56 +0,0 @@ -[2026-05-28 00:38:29] [DRY-RUN] Starting dry-run validation from version 2.8.44 to 2.8.45... -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on CURRENT_VERSION.txt matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on mysqltuner.pl matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on USAGE.md matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on README.md matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on SECURITY.md matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on MEMORY_DB.md matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated update on Changelog matches version schema and compiles cleanly. -[2026-05-28 00:38:29] [DRY-RUN] [OK] Simulated path for new release notes: /home/jmren/GIT_REPOS/MySQLTuner-perl/build/../releases/v2.8.45.md -[2026-05-28 00:38:29] [DRY-RUN] Dry-run validation completed successfully. All files matches version schemas. -[2026-05-28 00:38:47] [MAKE] Starting generate_usage... -[2026-05-28 00:39:23] [MAKE] Finished generate_usage. -[2026-05-28 00:41:14] [MAKE] Starting generate_usage... -[2026-05-28 00:41:49] [MAKE] Finished generate_usage. -[2026-05-28 00:44:29] [DRY-RUN] Starting dry-run validation from version 2.8.44 to 2.8.45... -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on CURRENT_VERSION.txt matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on mysqltuner.pl matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on USAGE.md matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on README.md matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on SECURITY.md matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on MEMORY_DB.md matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated update on Changelog matches version schema and compiles cleanly. -[2026-05-28 00:44:29] [DRY-RUN] [OK] Simulated path for new release notes: /home/jmren/GIT_REPOS/MySQLTuner-perl/build/../releases/v2.8.45.md -[2026-05-28 00:44:29] [DRY-RUN] Dry-run validation completed successfully. All files matches version schemas. -[2026-05-28 00:48:41] [DRY-RUN] Starting dry-run validation from version 2.8.44 to 2.8.45... -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on CURRENT_VERSION.txt matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on mysqltuner.pl matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on USAGE.md matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on README.md matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on SECURITY.md matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on MEMORY_DB.md matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated update on Changelog matches version schema and compiles cleanly. -[2026-05-28 00:48:41] [DRY-RUN] [OK] Simulated path for new release notes: /home/jmren/GIT_REPOS/MySQLTuner-perl/build/../releases/v2.8.45.md -[2026-05-28 00:48:41] [DRY-RUN] Dry-run validation completed successfully. All files matches version schemas. -[2026-05-28 00:50:06] [MAKE] Starting generate_usage... -[2026-05-28 00:50:41] [MAKE] Finished generate_usage. -[2026-05-28 07:56:46] [MAKE] Starting generate_usage... -[2026-05-28 07:57:00] [MAKE] Finished generate_usage. -[2026-05-28 07:57:00] [MAKE] Starting generate_release_notes... -[2026-05-28 07:57:14] [MAKE] Finished generate_release_notes. -[2026-06-04 06:51:17] [MAKE] Starting generate_usage... -[2026-06-04 06:51:52] [MAKE] Finished generate_usage. -[2026-06-04 06:51:52] [MAKE] Starting generate_release_notes... -[2026-06-04 06:52:27] [MAKE] Finished generate_release_notes. -[2026-06-04 08:32:37] [MAKE] Starting generate_usage... -[2026-06-04 08:32:55] [MAKE] Finished generate_usage. -[2026-06-04 08:32:55] [MAKE] Starting generate_release_notes... -[2026-06-04 08:33:10] [MAKE] Finished generate_release_notes. -[2026-06-04 08:33:51] [MAKE] Starting generate_usage... -[2026-06-04 08:34:10] [MAKE] Finished generate_usage. -[2026-06-04 08:34:10] [MAKE] Starting generate_release_notes... -[2026-06-04 08:34:26] [MAKE] Finished generate_release_notes. -[2026-06-04 09:36:52] [MAKE] Starting generate_usage... -[2026-06-04 09:37:30] [MAKE] Finished generate_usage. -[2026-06-04 09:37:30] [MAKE] Starting generate_release_notes... -[2026-06-04 09:38:12] [MAKE] Finished generate_release_notes. From 5f9cebb4cc3fea172ada66d0cca5c710141b0fd6 Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Tue, 16 Jun 2026 01:39:22 +0200 Subject: [PATCH 03/16] feat(report): implement Phase 13 sectional global indicators and KPIs --- .agent/README.md | 2 +- POTENTIAL_ISSUES.md | 7 +- ROADMAP.md | 18 +- mysqltuner.pl | 428 +++++++++++++++++++++++++++++++++++++- tests/unit_phase13_kpis.t | 209 +++++++++++++++++++ 5 files changed, 646 insertions(+), 18 deletions(-) create mode 100644 tests/unit_phase13_kpis.t diff --git a/.agent/README.md b/.agent/README.md index 06b3cd57c..3e748ae64 100644 --- a/.agent/README.md +++ b/.agent/README.md @@ -48,4 +48,4 @@ This directory contains the project's technical constitution, specialized skills --- -*Generated automatically by `/doc-sync`* \ No newline at end of file +*Generated automatically by `/doc-sync`* diff --git a/POTENTIAL_ISSUES.md b/POTENTIAL_ISSUES.md index 728544e36..6c1ded88f 100644 --- a/POTENTIAL_ISSUES.md +++ b/POTENTIAL_ISSUES.md @@ -115,12 +115,13 @@ This file records anomalies discovered during laboratory testing (Perl warnings, - Binlog checksum, doublewrite consistency: NOT implemented - **Status**: Phase 9 partially implemented -#### PI-016: ROADMAP Phases 10-12 — Not started +#### PI-016: ROADMAP Phases 11-12 — Not started - Workload Analysis & Traffic Profiling: Not implemented - Advanced Log Parser & Lock Monitoring: Not implemented -- Sectional Global Indicators: Not implemented -#### PI-017: ROADMAP Phase 13 (Export Optimization) — COMPLETED ✅ +#### PI-017: ROADMAP Phase 13 (Sectional Global Indicators) — COMPLETED ✅ + +#### PI-018: ROADMAP Phase 14 (Export Optimization) — COMPLETED ✅ --- diff --git a/ROADMAP.md b/ROADMAP.md index 6422fe775..fe3d9bc5f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -205,18 +205,18 @@ To ensure consistency and high-density development, the following roles are defi * [ ] **Correlation Engine (Experimental)**: * [ ] **Temporal Event Linking**: Logic to link error log timestamps with Performance Schema wait events or high CPU load detected during execution. -### [Phase 13: Sectional Global Indicators & KPIs](file:///documentation/specifications/roadmap_phase_xii_sectional_indicators.md) [NOT STARTED] +### [Phase 13: Sectional Global Indicators & KPIs](file:///documentation/specifications/roadmap_phase_xii_sectional_indicators.md) [COMPLETED] > Previously Phase 12. -* [ ] **Unified Health Dashboard**: - * [ ] **Sectional Health Scoring**: Implementation of a 0-100 KPI for each major diagnostic area (Storage Engine, Security, Replication, SQL Modeling). - * [ ] **Critical Findings Executive Summary**: Automated prioritization of the top 3 items per section with color-coded badges (🔴 Critical, 🟡 Finding, 🟢 Optimal). -* [ ] **Efficiency & Resource Mapping**: - * [ ] **Throughput Efficiency Index**: Real-time ratio analysis of logical work (Queries/sec) vs physical resource consumption (`Innodb_buffer_pool_read_requests`). - * [ ] **Resource Saturation Heatmap**: Visual representation of proximity to system limits (CPU/MEM/IO/Connections). -* [ ] **Comparative Insights**: - * [ ] **Historical Performance Deltas**: Sectional trend analysis identifying areas of performance regression or improvement based on previous run data. +* [x] **Unified Health Dashboard**: + * [x] **Sectional Health Scoring**: Implementation of a 0-100 KPI for each major diagnostic area (Storage Engine, Security, Replication, SQL Modeling). + * [x] **Critical Findings Executive Summary**: Automated prioritization of the top 3 items per section with color-coded badges (🔴 Critical, 🟡 Finding, 🟢 Optimal). +* [x] **Efficiency & Resource Mapping**: + * [x] **Throughput Efficiency Index**: Real-time ratio analysis of logical work (Queries/sec) vs physical resource consumption (`Innodb_buffer_pool_read_requests`). + * [x] **Resource Saturation Heatmap**: Visual representation of proximity to system limits (CPU/MEM/IO/Connections). +* [x] **Comparative Insights**: + * [x] **Historical Performance Deltas**: Sectional trend analysis identifying areas of performance regression or improvement based on previous run data. ### [Phase 14: Export Optimization & Dumpdir Hardening](file:///documentation/specifications/roadmap_phase_xiii_export_optimization.md) [COMPLETED] diff --git a/mysqltuner.pl b/mysqltuner.pl index 4a18e3e42..77d972569 100755 --- a/mysqltuner.pl +++ b/mysqltuner.pl @@ -1006,6 +1006,160 @@ sub calculate_health_score { $result{'HealthScore'} = $score; $result{'HealthScoreDetails'} = \%details; $mycalc{'WeightedHealthScore'} = $score; + + calculate_sectional_health_scores(); +} + +sub calculate_sectional_health_scores { + # 1. General Statistics KPI + my $gen_score = 100; + + my $uptime = $mystat{'Uptime'} // 0; + if ($uptime < 86400) { + $gen_score -= 20; + } + + my @load = get_load_average(); + if (@load) { + my $load_1 = $load[0] // 0; + my $cpu_cores = $mycalc{'physical_cpu_cores'} // $mycalc{'cpu_cores'} // 1; + if ($cpu_cores > 0 && ($load_1 / $cpu_cores) > 1.0) { + $gen_score -= 20; + } + } + + if ( grep { /swappiness/i } @generalrec ) { + $gen_score -= 20; + } + + my $other_mem = get_other_process_memory() // 0; + if ( ( $physical_memory // 0 ) > 0 && ($other_mem / $physical_memory) > 0.3 ) { + $gen_score -= 20; + } + $gen_score = 0 if $gen_score < 0; + + # 2. Storage Engine KPI + my $storage_score = 100; + + my $read_eff = $mycalc{'pct_read_efficiency'} // 100; + if ($read_eff < 95) { + $storage_score -= 25; + } + + my $temp_disk = $mycalc{'pct_temp_disk'} // 0; + if ($temp_disk > 25) { + $storage_score -= 25; + } + + my $table_hit = $mycalc{'table_cache_hit_rate'} // 100; + if ($table_hit < 50) { + $storage_score -= 25; + } + + my $redo_adjust = grep { /innodb_log_file_size/i || /innodb_redo_log_capacity/i } @adjvars; + if ($redo_adjust) { + $storage_score -= 25; + } + $storage_score = 0 if $storage_score < 0; + + # 3. Security & Compliance KPI + my $sec_score = 100; + $sec_score -= scalar(@secrec) * 15; + $sec_score = 0 if $sec_score < 0; + + # 4. Replication & HA KPI + my $repl_score = 100; + if ( %myrepl ) { + my $lag = $myrepl{'Seconds_Behind_Source'} // $myrepl{'Seconds_Behind_Replica'}; + if (defined $lag && $lag > 60) { + $repl_score -= 40; + } + my $io_run = $myrepl{'Replica_IO_Running'} // $myrepl{'Slave_IO_Running'} // 'Yes'; + my $sql_run = $myrepl{'Replica_SQL_Running'} // $myrepl{'Slave_SQL_Running'} // 'Yes'; + if ($io_run =~ /No/i || $sql_run =~ /No/i) { + $repl_score -= 35; + } + my $gtid = $myvar{'gtid_mode'} // 'OFF'; + if ($gtid =~ /OFF/i) { + $repl_score -= 25; + } + } + $repl_score = 0 if $repl_score < 0; + + # 5. SQL Modeling KPI + my $model_score = 100; + $model_score -= scalar(@modeling) * 10; + $model_score = 0 if $model_score < 0; + + $result{'SectionalHealthScore'}{'General'} = int($gen_score); + $result{'SectionalHealthScore'}{'Storage'} = int($storage_score); + $result{'SectionalHealthScore'}{'Security'} = int($sec_score); + $result{'SectionalHealthScore'}{'Replication'} = int($repl_score); + $result{'SectionalHealthScore'}{'Modeling'} = int($model_score); + + # Calculate Resource Saturation Heatmap + my $cpu_sat = 0; + if (@load) { + my $load_1 = $load[0] // 0; + my $cpu_cores = $mycalc{'physical_cpu_cores'} // $mycalc{'cpu_cores'} // 1; + $cpu_sat = ($cpu_cores > 0) ? ($load_1 / $cpu_cores) * 100 : 0; + $cpu_sat = 100 if $cpu_sat > 100; + } + + my $mem_sat = $mycalc{'pct_max_physical_memory'} // 0; + $mem_sat =~ s/%//g; + $mem_sat = 100 if $mem_sat > 100; + + my $conn_sat = $mycalc{'pct_connections_used'} // 0; + $conn_sat =~ s/%//g; + $conn_sat = 100 if $conn_sat > 100; + + my $read_eff_val = $mycalc{'pct_read_efficiency'} // 100; + my $disk_read_pressure = 100 - $read_eff_val; + my $scaled_read_pressure = $disk_read_pressure * 20; + my $temp_disk_val = $mycalc{'pct_temp_disk'} // 0; + my $io_sat = ($scaled_read_pressure > $temp_disk_val) ? $scaled_read_pressure : $temp_disk_val; + $io_sat = 100 if $io_sat > 100; + + $result{'ResourceSaturation'}{'CPU'} = int($cpu_sat); + $result{'ResourceSaturation'}{'Memory'} = int($mem_sat); + $result{'ResourceSaturation'}{'Connections'} = int($conn_sat); + $result{'ResourceSaturation'}{'IO'} = int($io_sat); + + # Calculate Throughput Efficiency Index + my $qps_val = ( ( $mystat{'Uptime'} || 0 ) > 0 ) ? ( $mystat{'Questions'} || 0 ) / $mystat{'Uptime'} : 0; + my $logical_reads_sec = ( $uptime > 0 ) ? ( $mystat{'Innodb_buffer_pool_read_requests'} // 0 ) / $uptime : 0; + my $tei = ( $logical_reads_sec > 0 ) ? ( $qps_val / $logical_reads_sec ) : 0; + + $result{'ThroughputEfficiency'}{'QPS'} = sprintf("%.3f", $qps_val) + 0; + $result{'ThroughputEfficiency'}{'LogicalReadsSec'} = sprintf("%.3f", $logical_reads_sec) + 0; + $result{'ThroughputEfficiency'}{'Index'} = sprintf("%.5f", $tei) + 0; +} + +sub get_top_findings { + my $list_ref = shift; + my @items = (); + foreach my $it (@$list_ref) { + push @items, format_recommendation_item($it); + } + + my @scored = (); + foreach my $item (@items) { + my $score = 1; + if ($item =~ /(dangerously high|insecure|vulnerability|CVE|not running|aborted|unencrypted|anonymous|remove|risk|critical|warning|incorrect|mismatch)/i) { + $score = 2; + } + push @scored, { text => $item, score => $score }; + } + + my @sorted = sort { $b->{score} <=> $a->{score} } @scored; + + my @res = (); + for (my $i = 0; $i < 3 && $i < @sorted; $i++) { + my $badge = ($sorted[$i]->{score} == 2) ? 'Critical' : 'Finding'; + push @res, { text => $sorted[$i]->{text}, badge => $badge }; + } + return @res; } sub display_health_score { @@ -4000,6 +4154,20 @@ sub get_other_process_memory { return $totalMemOther; } +sub get_load_average { + if ( -f "/proc/loadavg" ) { + my $content = file2string("/proc/loadavg") // ''; + if ( $content =~ /^([\d.]+)\s+([\d.]+)\s+([\d.]+)/ ) { + return ( $1, $2, $3 ); + } + } + my $uptime_output = execute_system_command("uptime") // ''; + if ( $uptime_output =~ /load average(?:s)?:?\s+([\d.]+),?\s+([\d.]+),?\s+([\d.]+)/i ) { + return ( $1, $2, $3 ); + } + return (); +} + sub get_os_release { if ( -f "/etc/lsb-release" ) { my @info_release = get_file_contents "/etc/lsb-release"; @@ -11512,8 +11680,9 @@ sub historical_comparison { return; } - infoprint "Comparing current results with snapshot from: " - . ( $old->{'General'}{'Date'} // 'unknown' ); + my $snapshot_date = $old->{'General'}{'Date'} // 'unknown'; + infoprint "Comparing current results with snapshot from: " . $snapshot_date; + $result{'Trends'}{'SnapshotDate'} = $snapshot_date; # 1. Compare QPS if ( defined $result{'Stats'}{'QPS'} && defined $old->{'Stats'}{'QPS'} ) { @@ -11523,20 +11692,24 @@ sub historical_comparison { ? ( $diff / $old->{'Stats'}{'QPS'} ) * 100 : 0; my $trend = ( $diff >= 0 ) ? "+" : ""; - infoprint sprintf( + my $qps_trend = sprintf( "QPS Trend: %.2f -> %.2f (%s%.2f%%)", $old->{'Stats'}{'QPS'}, $result{'Stats'}{'QPS'}, $trend, $pct ); + infoprint $qps_trend; + $result{'Trends'}{'QPS'} = $qps_trend; } # Compare Health Score if ( defined $result{'HealthScore'} && defined $old->{'HealthScore'} ) { my $diff = $result{'HealthScore'} - $old->{'HealthScore'}; my $trend = ( $diff > 0 ) ? "+" : ""; - infoprint sprintf( "Health Score Trend: %d -> %d (%s%d)", + my $score_trend = sprintf( "Health Score Trend: %d -> %d (%s%d)", $old->{'HealthScore'}, $result{'HealthScore'}, $trend, $diff ); + infoprint $score_trend; + $result{'Trends'}{'HealthScore'} = $score_trend; } # 2. Compare Total Data Size @@ -11545,10 +11718,25 @@ sub historical_comparison { { my $diff = $result{'Stats'}{'Total Data Size'} - $old->{'Stats'}{'Total Data Size'}; - infoprint "Data Growth: " + my $size_trend = "Data Growth: " . hr_bytes( $old->{'Stats'}{'Total Data Size'} ) . " -> " . hr_bytes( $result{'Stats'}{'Total Data Size'} ) . " (" . hr_bytes($diff) . ")"; + infoprint $size_trend; + $result{'Trends'}{'TotalDataSize'} = $size_trend; + } + + # Compare sectional scores + foreach my $sec ('General', 'Storage', 'Security', 'Replication', 'Modeling') { + if (defined $result{'SectionalHealthScore'}{$sec} && defined $old->{'SectionalHealthScore'}{$sec}) { + my $diff = $result{'SectionalHealthScore'}{$sec} - $old->{'SectionalHealthScore'}{$sec}; + my $trend = ($diff > 0) ? "+" : ""; + $result{'Trends'}{'Sectional'}{$sec} = sprintf("%d -> %d (%s%d)", + $old->{'SectionalHealthScore'}{$sec}, + $result{'SectionalHealthScore'}{$sec}, + $trend, $diff + ); + } } push @adjvars, "Historical comparison performed against " . basename($file); @@ -12662,6 +12850,97 @@ sub dump_result { my $raw_output_html = join( "\n", map { escape_html($_) } @raw_output_lines ); + # Phase 13: Calculate Sectional Health Score variables + my $kpi_gen = $result{'SectionalHealthScore'}{'General'} // 100; + my $kpi_stor = $result{'SectionalHealthScore'}{'Storage'} // 100; + my $kpi_sec = $result{'SectionalHealthScore'}{'Security'} // 100; + my $kpi_repl = $result{'SectionalHealthScore'}{'Replication'} // 100; + my $kpi_mode = $result{'SectionalHealthScore'}{'Modeling'} // 100; + + # Prioritized Top Findings lists + my @general_top = get_top_findings(\@generalrec); + my @storage_top = get_top_findings(\@adjvars); + my @security_top = get_top_findings(\@secrec); + my @repl_recs = grep { /(replica|sla[v]e|gtid|replication|binlog|relay)/i } @generalrec; + my @replication_top = get_top_findings(\@repl_recs); + my @modeling_top = get_top_findings(\@modeling); + + my $format_top_findings = sub { + my ($title, $findings_ref) = @_; + my @findings = @$findings_ref; + if (!@findings) { + return "
" + . "

$title🟢 Optimal

" + . "

No critical issues or recommendations detected in this area.

" + . "
"; + } + my $items_html = ''; + foreach my $f (@findings) { + my $badge_color = ($f->{badge} eq 'Critical') ? 'text-rose-400 bg-rose-500/10 border-rose-500/20' : 'text-amber-400 bg-amber-500/10 border-amber-500/20'; + $items_html .= "
  • " + . "" . $f->{badge} . "" + . "" . escape_html($f->{text}) . "" + . "
  • "; + } + return "
    " + . "

    $title⚠️ Action Required

    " + . "
      $items_html
    " + . "
    "; + }; + + my $gen_findings_html = $format_top_findings->('General Stats', \@general_top); + my $store_findings_html = $format_top_findings->('Storage & Performance', \@storage_top); + my $sec_findings_html = $format_top_findings->('Security & Compliance', \@security_top); + my $repl_findings_html = $format_top_findings->('Replication & HA', \@replication_top); + my $model_findings_html = $format_top_findings->('SQL Modeling', \@modeling_top); + + # Throughput Efficiency Index + my $tei_qps = $result{'ThroughputEfficiency'}{'QPS'} // 0.0; + my $tei_reads = $result{'ThroughputEfficiency'}{'LogicalReadsSec'} // 0.0; + my $tei_index = $result{'ThroughputEfficiency'}{'Index'} // 0.00000; + + # Resource Saturation Heatmap + my $sat_cpu = $result{'ResourceSaturation'}{'CPU'} // 0; + my $sat_mem = $result{'ResourceSaturation'}{'Memory'} // 0; + my $sat_conn = $result{'ResourceSaturation'}{'Connections'} // 0; + my $sat_io = $result{'ResourceSaturation'}{'IO'} // 0; + + my $get_sat_color = sub { + my $val = shift; + return $val > 85 ? 'bg-rose-500' : ($val > 60 ? 'bg-amber-400' : 'bg-emerald-500'); + }; + my $cpu_color = $get_sat_color->($sat_cpu); + my $mem_color = $get_sat_color->($sat_mem); + my $conn_color = $get_sat_color->($sat_conn); + my $io_color = $get_sat_color->($sat_io); + + # Historical Trend Deltas + my $historical_deltas_html = ''; + if (defined $result{'Trends'}) { + my $qps_delta = $result{'Trends'}{'QPS'} // 'N/A'; + my $score_delta = $result{'Trends'}{'HealthScore'} // 'N/A'; + my $size_delta = $result{'Trends'}{'TotalDataSize'} // 'N/A'; + my $sec_deltas = ''; + foreach my $sec ('General', 'Storage', 'Security', 'Replication', 'Modeling') { + my $d = $result{'Trends'}{'Sectional'}{$sec} // 'N/A'; + $sec_deltas .= "
    $sec: $d
    "; + } + $historical_deltas_html = <<"HTML"; +
    +

    Historical Performance Deltas

    +
    +
    QPS: $qps_delta
    +
    Health Score: $score_delta
    +
    Data Size: $size_delta
    +
    +

    Sectional Score Trends

    + $sec_deltas +
    +
    +
    +HTML + } + # Determine health score color class my $score_color_class = $score > 80 @@ -12794,11 +13073,150 @@ sub dump_result { + $historical_deltas_html
    + +
    +
    +

    Sectional Indicators & KPIs Dashboard

    + Phase 13 Unified View +
    + + +
    + +
    + General Stats + $kpi_gen% +
    +
    +
    +
    + +
    + Storage & Perf + $kpi_stor% +
    +
    +
    +
    + +
    + Security + $kpi_sec% +
    +
    +
    +
    + +
    + Replication & HA + $kpi_repl% +
    +
    +
    +
    + +
    + SQL Modeling + $kpi_mode% +
    +
    +
    +
    +
    + + +
    + +
    +

    Resource Saturation Heatmap

    + +
    +
    + CPU Load Saturation + $sat_cpu% +
    +
    +
    +
    +
    + +
    +
    + Memory Saturation + $sat_mem% +
    +
    +
    +
    +
    + +
    +
    + Connections Saturation + $sat_conn% +
    +
    +
    +
    +
    + +
    +
    + Disk I/O Pressure + $sat_io% +
    +
    +
    +
    +
    +
    + + +
    +
    +

    Throughput Efficiency Index

    +
    +
    + Logical Work + $tei_qps QPS +
    +
    + Buffer Logical Reads + $tei_reads/s +
    +
    +
    +
    +
    +
    + Efficiency Ratio + Queries completed per logical page read +
    + $tei_index +
    +
    +
    +
    + + +
    +

    Prioritized Executive Summary Findings

    +
    + $gen_findings_html + $store_findings_html + $sec_findings_html + $repl_findings_html + $model_findings_html +
    +
    +
    +
    diff --git a/tests/unit_phase13_kpis.t b/tests/unit_phase13_kpis.t new file mode 100644 index 000000000..0d4df5412 --- /dev/null +++ b/tests/unit_phase13_kpis.t @@ -0,0 +1,209 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; + +# Get the script path +my $script_path = File::Spec->rel2abs(File::Spec->catfile(dirname(__FILE__), '..', 'mysqltuner.pl')); + +# Load MySQLTuner logic +{ + local @ARGV = ('--silent'); + package main; + require $script_path; +} + +package main; + +# Write a mock JSON file for historical delta testing +my $mock_old_file = 'mock_old.json'; +open(my $fh, '>', $mock_old_file) or die "Could not open $mock_old_file: $!"; +print $fh q({ + "General": { "Date": "2026-06-01" }, + "Stats": { + "QPS": 10.0, + "Total Data Size": 83886080 + }, + "HealthScore": 80, + "SectionalHealthScore": { + "General": 95, + "Storage": 70, + "Security": 90, + "Replication": 100, + "Modeling": 90 + } +}); +close($fh); + +# Ensure cleanup on exit +END { + if (-e 'mock_old.json') { + unlink('mock_old.json'); + } +} + +# 1. Test get_load_average with mock file2string or fallback to execute_system_command +{ + no warnings 'redefine'; + local *main::file2string = sub { + my $path = shift; + if ($path eq '/proc/loadavg') { + return "1.25 0.80 0.50 2/250 12345\n"; + } + return undef; + }; + + my @load = main::get_load_average(); + is_deeply(\@load, ['1.25', '0.80', '0.50'], "get_load_average should parse /proc/loadavg correctly"); +} + +{ + no warnings 'redefine'; + local *main::file2string = sub { return undef; }; + local *main::execute_system_command = sub { + my $cmd = shift; + if ($cmd eq 'uptime') { + return " 01:25:00 up 10 days, 1:23, 1 user, load average: 0.75, 1.10, 1.35\n"; + } + return undef; + }; + + my @load = main::get_load_average(); + is_deeply(\@load, ['0.75', '1.10', '1.35'], "get_load_average should fallback to uptime command"); +} + +# 2. Reset and Mock Sectional Indicators & Health Scores +sub reset_state { + @main::generalrec = (); + @main::sysrec = (); + @main::secrec = (); + @main::modeling = (); + @main::adjvars = (); + %main::mycalc = (); + %main::mystat = (); + %main::myrepl = (); + %main::result = (); + $main::physical_memory = 16 * 1024 * 1024 * 1024; # 16 GB + $main::swap_memory = 2 * 1024 * 1024 * 1024; # 2 GB +} + +# 2a. Test Optimal state: All scores 100 +reset_state(); +$main::mystat{'Uptime'} = 100000; # > 86400 +$main::mycalc{'pct_read_efficiency'} = 98.0; +$main::mycalc{'pct_temp_disk'} = 10.0; +$main::mycalc{'table_cache_hit_rate'} = 80.0; +$main::mystat{'Questions'} = 200000; +$main::mystat{'Innodb_buffer_pool_read_requests'} = 500000; + +# Load mock setup for loadavg +{ + no warnings 'redefine'; + local *main::get_load_average = sub { return (0.1, 0.2, 0.3) }; + local *main::get_other_process_memory = sub { return 1024 * 1024; }; # tiny + main::calculate_sectional_health_scores(); +} + +is($main::result{'SectionalHealthScore'}{'General'}, 100, "Optimal General score should be 100"); +is($main::result{'SectionalHealthScore'}{'Storage'}, 100, "Optimal Storage score should be 100"); +is($main::result{'SectionalHealthScore'}{'Security'}, 100, "Optimal Security score should be 100"); +is($main::result{'SectionalHealthScore'}{'Replication'}, 100, "Optimal Replication score should be 100"); +is($main::result{'SectionalHealthScore'}{'Modeling'}, 100, "Optimal SQL Modeling score should be 100"); + +# 2b. Test deductions +reset_state(); +$main::mystat{'Uptime'} = 5000; # < 86400 -> -20 +$main::mycalc{'pct_read_efficiency'} = 90.0; # < 95 -> -25 +$main::mycalc{'pct_temp_disk'} = 30.0; # > 25 -> -25 +push @main::secrec, "Vulnerable configuration."; # 1 item -> -15 +push @main::modeling, "Missing primary key."; # 1 item -> -10 + +{ + no warnings 'redefine'; + local *main::get_load_average = sub { return (8.0, 8.0, 8.0) }; + $main::mycalc{'cpu_cores'} = 4; # load_1 / cpu_cores = 2.0 > 1.0 -> -20 + local *main::get_other_process_memory = sub { return 8 * 1024 * 1024 * 1024; }; # 50% RAM -> -20 + main::calculate_sectional_health_scores(); +} + +# Print diagnostics for deduction test +diag("--- Diagnostic Info ---"); +diag("General Recs: " . join(", ", @main::generalrec)); +diag("Other Mem: " . main::get_other_process_memory()); +diag("Load Avg: " . join(", ", main::get_load_average())); +diag("Calculated General Score: " . $main::result{'SectionalHealthScore'}{'General'}); + +is($main::result{'SectionalHealthScore'}{'General'}, 40, "General score should deduct for uptime, load, and other processes memory"); +is($main::result{'SectionalHealthScore'}{'Storage'}, 50, "Storage score should deduct for read efficiency and temp disk tables"); +is($main::result{'SectionalHealthScore'}{'Security'}, 85, "Security score should deduct for 1 recommendation"); +is($main::result{'SectionalHealthScore'}{'Modeling'}, 90, "SQL Modeling score should deduct for 1 modeling finding"); + +# 3. Test Resource Saturation & Throughput Efficiency Index +reset_state(); +$main::mystat{'Uptime'} = 10000; +$main::mycalc{'pct_max_physical_memory'} = "88%"; +$main::mycalc{'pct_connections_used'} = "75.5%"; +$main::mycalc{'pct_read_efficiency'} = 97.5; # disk read pressure = 2.5 * 20 = 50% +$main::mycalc{'pct_temp_disk'} = 40.0; # max(50, 40) = 50% IO sat +$main::mystat{'Questions'} = 50000; # QPS = 5.0 +$main::mystat{'Innodb_buffer_pool_read_requests'} = 100000; # Reads/sec = 10.0 +# TEI = QPS / Reads/sec = 5.0 / 10.0 = 0.5 + +{ + no warnings 'redefine'; + local *main::get_load_average = sub { return (2.5, 2.5, 2.5) }; + $main::mycalc{'cpu_cores'} = 4; # CPU sat = 2.5 / 4 = 62.5% -> 62% + local *main::get_other_process_memory = sub { return 0; }; + main::calculate_sectional_health_scores(); +} + +is($main::result{'ResourceSaturation'}{'CPU'}, 62, "CPU saturation calculation"); +is($main::result{'ResourceSaturation'}{'Memory'}, 88, "Memory saturation calculation"); +is($main::result{'ResourceSaturation'}{'Connections'}, 75, "Connections saturation calculation"); +is($main::result{'ResourceSaturation'}{'IO'}, 50, "Disk I/O saturation calculation"); + +is($main::result{'ThroughputEfficiency'}{'QPS'}, 5.0, "Throughput Efficiency QPS"); +is($main::result{'ThroughputEfficiency'}{'LogicalReadsSec'}, 10.0, "Throughput Efficiency Reads/sec"); +is($main::result{'ThroughputEfficiency'}{'Index'}, 0.5, "Throughput Efficiency Index (TEI)"); + +# 4. Test get_top_findings ranking logic +reset_state(); +push @main::secrec, "Unencrypted connection detected."; # Critical keyword +push @main::secrec, "Check password complexity."; # Standard finding +push @main::secrec, "Accounts with empty password (risk)."; # Critical keyword (contains risk) +push @main::secrec, "Some minor issue."; + +my @top_sec = main::get_top_findings(\@main::secrec); +is(scalar(@top_sec), 3, "get_top_findings returns at most 3 items"); +is($top_sec[0]->{badge}, "Critical", "First item should be critical due to keyword"); +is($top_sec[1]->{badge}, "Critical", "Second item should be critical due to keyword"); +is($top_sec[2]->{badge}, "Finding", "Third item should be standard finding"); + +# 5. Test historical delta trend analysis +reset_state(); +$main::result{'Stats'}{'QPS'} = 12.5; +$main::result{'HealthScore'} = 85; +$main::result{'Stats'}{'Total Data Size'} = 104857600; # 100MB +$main::result{'SectionalHealthScore'}{'General'} = 90; +$main::result{'SectionalHealthScore'}{'Storage'} = 80; +$main::result{'SectionalHealthScore'}{'Security'} = 95; +$main::result{'SectionalHealthScore'}{'Replication'} = 100; +$main::result{'SectionalHealthScore'}{'Modeling'} = 90; + +{ + no warnings 'redefine'; + $main::opt{'compare-file'} = $mock_old_file; + main::historical_comparison(); +} + +is($main::result{'Trends'}{'SnapshotDate'}, '2026-06-01', "Snapshot date correctly parsed"); +like($main::result{'Trends'}{'QPS'}, qr/\+25\.00%/, "QPS trend delta should show +25.00%"); +like($main::result{'Trends'}{'HealthScore'}, qr/\+5/, "Health score trend delta should show +5"); +like($main::result{'Trends'}{'TotalDataSize'}, qr/80.0M -> 100.0M \(20.0M\)/, "Data growth should show correct formatting"); +is($main::result{'Trends'}{'Sectional'}{'General'}, '95 -> 90 (-5)', "General score delta trend"); +is($main::result{'Trends'}{'Sectional'}{'Storage'}, '70 -> 80 (+10)', "Storage score delta trend"); + +done_testing(); From c23198afb80f2e2773e258d73040aa931a487ec2 Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Wed, 17 Jun 2026 17:35:40 +0200 Subject: [PATCH 04/16] feat(report): add verbose timings, step percentages, and snapshot summary --- Changelog | 4 + .../verbose_execution_timings.md | 34 ++++++++ mysqltuner.pl | 70 ++++++++++++++- releases/v2.9.0.md | 4 + tests/verbose_timing.t | 86 +++++++++++++++++++ 5 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 documentation/specifications/verbose_execution_timings.md create mode 100644 tests/verbose_timing.t diff --git a/Changelog b/Changelog index 4d8017646..9f0cb5385 100644 --- a/Changelog +++ b/Changelog @@ -4,11 +4,15 @@ - chore(main): whitelist deps and system commit scopes in check_compliance.pl to support Dependabot and host metrics commits. - feat(cli): create an agent-ready output format (JSON/YAML) so that MySQLTuner can be easily integrated by AI agents. +- feat(report): add verbose execution timing measurements for each section, showing both elapsed time and its percentage relative to the total script execution time. - feat(report): finalize a complete HTML report file beginning in v2.8.45. +- feat(report): move dump_csv_files execution step to immediately before make_recommendations. +- feat(report): print an environment audit snapshot summary (server, user, RAM, swap, versions, uptime) right after get_all_vars. - feat(report): support historical comparison of database diagnostics and performance metrics over time. - fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. - fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables. - fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks. +- test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t. - test(versions): add unit tests for version caching and comparisons in tests/unit_versions.t. 2.8.45 2026-06-04 diff --git a/documentation/specifications/verbose_execution_timings.md b/documentation/specifications/verbose_execution_timings.md new file mode 100644 index 000000000..66da19150 --- /dev/null +++ b/documentation/specifications/verbose_execution_timings.md @@ -0,0 +1,34 @@ +# Specification: Verbose Execution Timings + +## Goal + +Add execution timing information for each section and the total execution time at the end of the MySQLTuner run when verbose mode is active. + +## Scenario + +- **Test Case**: Run MySQLTuner with `--verbose` or `-v`. +- **Evidence**: + - At the end of each printed block (e.g., after `MyISAM Metrics`), its individual execution time is printed. + - Before the final `✔ Terminated successfully` message, a summary block (`Execution Times`) listing all section names with their durations and the total execution time is printed. +- **Example Console Output**: + ``` + -------- MyISAM Metrics ---------------------------------------------------------------------------- + ... + [--] MyISAM Metrics execution time: 0.123s + + ... + + -------- Execution Times --------------------------------------------------------------------------- + [--] Storage Engine Statistics: 0.045s + [--] MyISAM Metrics: 0.123s + ... + [--] Total Execution Time: 1.789s + ``` + +## Rules + +1. Measure execution times of all blocks defined by `subheaderprint` calls. +2. Safe dynamic loading of `Time::HiRes` to ensure compatibility and lack of CPAN dependencies. +3. Fallback to `time()` when `Time::HiRes` is not available. +4. Timings must only print when `$opt{'verbose'}` is set. +5. Timing outputs must be placed before the terminal `✔ Terminated successfully` message. diff --git a/mysqltuner.pl b/mysqltuner.pl index 77d972569..d7c79e86c 100755 --- a/mysqltuner.pl +++ b/mysqltuner.pl @@ -72,6 +72,7 @@ package main; our ( %result, %myvar, %real_vars, %mystat, %mycalc, %myrepl, %myreplicas, $dummyselect ); our %exported_manifest; +our ( $tuner_start_time, $current_section_name, $current_section_start, @section_timings ); our $failed_connection_attempts = 0; our $previous_failed_attempts = 0; @@ -1587,6 +1588,16 @@ sub infoprintcmd { } sub subheaderprint { + my $name = $_[0]; + my $now = eval { require Time::HiRes; Time::HiRes::time(); } || time(); + if ( defined $current_section_name && $opt{'verbose'} ) { + my $elapsed = $now - $current_section_start; + push @section_timings, [ $current_section_name, $elapsed ]; + infoprint sprintf("%s execution time: %.3fs", $current_section_name, $elapsed); + } + $current_section_name = $name; + $current_section_start = $now; + my $tln = 100; my $sln = 8; my $ln = length("@_") + 2; @@ -1595,6 +1606,58 @@ sub subheaderprint { prettyprint "-" x $sln . " @_ " . "-" x ( $tln - $ln - $sln ); } +sub stop_section_timing { + my $now = eval { require Time::HiRes; Time::HiRes::time(); } || time(); + if ( defined $current_section_name && $opt{'verbose'} ) { + my $elapsed = $now - $current_section_start; + push @section_timings, [ $current_section_name, $elapsed ]; + infoprint sprintf("%s execution time: %.3fs", $current_section_name, $elapsed); + } + $current_section_name = undef; +} + +sub print_execution_timings { + return unless $opt{'verbose'}; + + stop_section_timing(); + + my $total_now = eval { require Time::HiRes; Time::HiRes::time(); } || time(); + my $total_elapsed = $total_now - $tuner_start_time; + + subheaderprint "Execution Times"; + foreach my $timing (@section_timings) { + my ( $name, $elapsed ) = @$timing; + my $pct = $total_elapsed > 0 ? ( $elapsed / $total_elapsed ) * 100 : 0; + infoprint sprintf( "%-50s: %.3fs (%.1f%%)", $name, $elapsed, $pct ); + } + infoprint sprintf( "Total Execution Time: %.3fs", $total_elapsed ); +} + +sub print_audit_snapshot_summary { + subheaderprint "Audit Snapshot Summary"; + + my $host = $opt{'host'} || 'localhost'; + if ( $opt{'port'} ) { + $host .= ":$opt{'port'}"; + } + + my $db_user = select_one("SELECT CURRENT_USER()") || $opt{'user'} || 'unknown'; + + my $ram_str = defined($physical_memory) ? hr_bytes($physical_memory) : 'unknown'; + my $swap_str = defined($swap_memory) ? hr_bytes($swap_memory) : 'unknown'; + + my $db_ver = $myvar{'version'} // 'unknown'; + my $uptime_str = defined($mystat{'Uptime'}) ? pretty_uptime($mystat{'Uptime'}) : 'unknown'; + + infoprint "MySQLTuner Version : $tunerversion"; + infoprint "Server Connection : $host"; + infoprint "Database User : $db_user"; + infoprint "Database Version : $db_ver"; + infoprint "System Physical RAM: $ram_str"; + infoprint "System Swap Memory : $swap_str"; + infoprint "Database Uptime : $uptime_str"; +} + sub infoprinthcmd { subheaderprint "$_[0]"; infoprintcmd "$_[1]"; @@ -13599,6 +13662,7 @@ sub dump_csv_files { # BEGIN 'MAIN' # --------------------------------------------------------------------------- if ( !caller ) { + $tuner_start_time = eval { require Time::HiRes; Time::HiRes::time(); } || time(); parse_cli_args; # Parse CLI arguments setup_environment; # Initialize variables and handle early exits headerprint; # Header Print @@ -13609,9 +13673,9 @@ sub dump_csv_files { debugprint "MySQL FINAL Client : $mysqlcmd $mysqllogin"; debugprint "MySQL Admin FINAL Client : $mysqladmincmd $mysqllogin"; - dump_csv_files; # dump csv files os_setup; # Set up some OS variables get_all_vars; # Toss variables/status into hashes + print_audit_snapshot_summary; # Summary of the audit snapshot mysql_cloud_discovery; # Auto-discover cloud environment get_tuning_info; # Get information about the tuning connection calculations; # Calculate everything we need @@ -13625,7 +13689,9 @@ sub dump_csv_files { subheaderprint "Running feature: $feature"; $feature->(); } + dump_csv_files; # dump csv files make_recommendations; + print_execution_timings(); goodprint "Terminated successfully"; exit(0); } @@ -13653,8 +13719,10 @@ sub dump_csv_files { $section->(); } + dump_csv_files; # dump csv files make_recommendations; # Make recommendations based on stats dump_result; # Dump result if debug is on + print_execution_timings(); goodprint "Terminated successfully"; close_outputfile; # Close reportfile if needed diff --git a/releases/v2.9.0.md b/releases/v2.9.0.md index 80e044d99..2aeb092b9 100644 --- a/releases/v2.9.0.md +++ b/releases/v2.9.0.md @@ -9,11 +9,15 @@ - chore(main): whitelist deps and system commit scopes in check_compliance.pl to support Dependabot and host metrics commits. - feat(cli): create an agent-ready output format (JSON/YAML) so that MySQLTuner can be easily integrated by AI agents. +- feat(report): add verbose execution timing measurements for each section, showing both elapsed time and its percentage relative to the total script execution time. - feat(report): finalize a complete HTML report file beginning in v2.8.45. +- feat(report): move dump_csv_files execution step to immediately before make_recommendations. +- feat(report): print an environment audit snapshot summary (server, user, RAM, swap, versions, uptime) right after get_all_vars. - feat(report): support historical comparison of database diagnostics and performance metrics over time. - fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. - fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables. - fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks. +- test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t. - test(versions): add unit tests for version caching and comparisons in tests/unit_versions.t. ``` diff --git a/tests/verbose_timing.t b/tests/verbose_timing.t new file mode 100644 index 000000000..dfb84840e --- /dev/null +++ b/tests/verbose_timing.t @@ -0,0 +1,86 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +# Suppress warnings from mysqltuner.pl initialization +$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; + +# Load mysqltuner.pl as a library +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +require $script; + +# Ensure verbose is on, and clear state +$main::opt{'verbose'} = 1; +@main::section_timings = (); +@main::raw_output_lines = (); +$main::tuner_start_time = (eval { require Time::HiRes; Time::HiRes::time(); } || time()) - 1.0; +$main::current_section_name = undef; + +# Call subheaderprint twice to trigger timing print of first section +main::subheaderprint("Section A"); +$main::current_section_start = $main::current_section_start - 0.5; # Fake elapsed time of 0.5s +main::subheaderprint("Section B"); + +# Check if timing for Section A was printed +my @section_a_lines = grep { /Section A execution time:/ } @main::raw_output_lines; +ok(scalar @section_a_lines > 0, "Execution time for Section A is printed when verbose is enabled"); +like($section_a_lines[0], qr/Section A execution time: 0\.\d+s/, "Duration format is correct"); + +# Run print_execution_timings +@main::raw_output_lines = (); +main::print_execution_timings(); + +# Check if summary block is printed with percentages +my @summary_lines = grep { /Section B\s*:/ || /Total Execution Time:/ } @main::raw_output_lines; +ok(scalar @summary_lines >= 2, "Summary block and total execution time are printed at the end"); +my @pct_lines = grep { /\(\d+\.\d+%\)/ } @main::raw_output_lines; +ok(scalar @pct_lines >= 2, "Percentages are printed next to each section duration"); + +# Run again with verbose = 0 and verify NO timings are printed +$main::opt{'verbose'} = 0; +@main::section_timings = (); +@main::raw_output_lines = (); +$main::current_section_name = undef; + +main::subheaderprint("Section A"); +main::subheaderprint("Section B"); +main::print_execution_timings(); + +my @no_timing_lines = grep { /execution time:/ || /Total Execution Time:/ } @main::raw_output_lines; +is(scalar @no_timing_lines, 0, "No timing messages are printed when verbose is disabled"); + +# 3. Test print_audit_snapshot_summary +subtest 'print_audit_snapshot_summary' => sub { + @main::raw_output_lines = (); + $main::tunerversion = "9.9.9"; + $main::opt{'host'} = "127.0.0.1"; + $main::opt{'port'} = 3307; + $main::physical_memory = 16 * 1024 * 1024 * 1024; + $main::swap_memory = 4 * 1024 * 1024 * 1024; + $main::myvar{'version'} = "10.11.4-MariaDB"; + $main::mystat{'Uptime'} = 172800; + + # We mock select_one so that we don't try to query database for CURRENT_USER + no warnings 'redefine'; + local *main::select_one = sub { return 'root@localhost'; }; + + main::print_audit_snapshot_summary(); + + my $output = join("\n", @main::raw_output_lines); + like($output, qr/Audit Snapshot Summary/, "Header printed"); + like($output, qr/MySQLTuner Version : 9\.9\.9/, "MySQLTuner version matched"); + like($output, qr/Server Connection : 127\.0\.0\.1:3307/, "Connection matched"); + like($output, qr/Database User : root\@localhost/, "Database user matched"); + like($output, qr/Database Version : 10\.11\.4-MariaDB/, "Database version matched"); + like($output, qr/System Physical RAM: 16\.0G/, "System RAM matched"); + like($output, qr/System Swap Memory : 4\.0G/, "System Swap matched"); + like($output, qr/Database Uptime : 2d 0h 0m 0s/, "Database uptime matched"); +}; + +done_testing(); From 780c8615266131f9dc9980618a9999f0511f4e94 Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Tue, 23 Jun 2026 17:45:47 +0200 Subject: [PATCH 05/16] fix(main): calculate query cache efficiency using Com_select on MariaDB (MDEV-4981) --- Changelog | 2 + mysqltuner.pl | 333 +++++++++++++++++++++++++++------------------ releases/v2.9.0.md | 2 + tests/issue_927.t | 240 ++++++++++++++++++++++++++++++++ 4 files changed, 448 insertions(+), 129 deletions(-) create mode 100644 tests/issue_927.t diff --git a/Changelog b/Changelog index 9f0cb5385..0b2559114 100644 --- a/Changelog +++ b/Changelog @@ -9,9 +9,11 @@ - feat(report): move dump_csv_files execution step to immediately before make_recommendations. - feat(report): print an environment audit snapshot summary (server, user, RAM, swap, versions, uptime) right after get_all_vars. - feat(report): support historical comparison of database diagnostics and performance metrics over time. +- fix(main): calculate query cache efficiency using Com_select on MariaDB, where Com_select includes query cache hits (MDEV-4981). - fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. - fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables. - fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks. +- test(lab): add unit tests for query cache efficiency logic on MySQL and MariaDB in tests/issue_927.t. - test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t. - test(versions): add unit tests for version caching and comparisons in tests/unit_versions.t. diff --git a/mysqltuner.pl b/mysqltuner.pl index d7c79e86c..3b0d8bc6d 100755 --- a/mysqltuner.pl +++ b/mysqltuner.pl @@ -72,7 +72,10 @@ package main; our ( %result, %myvar, %real_vars, %mystat, %mycalc, %myrepl, %myreplicas, $dummyselect ); our %exported_manifest; -our ( $tuner_start_time, $current_section_name, $current_section_start, @section_timings ); +our ( + $tuner_start_time, $current_section_name, + $current_section_start, @section_timings +); our $failed_connection_attempts = 0; our $previous_failed_attempts = 0; @@ -1012,52 +1015,57 @@ sub calculate_health_score { } sub calculate_sectional_health_scores { + # 1. General Statistics KPI my $gen_score = 100; - + my $uptime = $mystat{'Uptime'} // 0; - if ($uptime < 86400) { + if ( $uptime < 86400 ) { $gen_score -= 20; } - + my @load = get_load_average(); if (@load) { - my $load_1 = $load[0] // 0; - my $cpu_cores = $mycalc{'physical_cpu_cores'} // $mycalc{'cpu_cores'} // 1; - if ($cpu_cores > 0 && ($load_1 / $cpu_cores) > 1.0) { + my $load_1 = $load[0] // 0; + my $cpu_cores = $mycalc{'physical_cpu_cores'} // $mycalc{'cpu_cores'} + // 1; + if ( $cpu_cores > 0 && ( $load_1 / $cpu_cores ) > 1.0 ) { $gen_score -= 20; } } - + if ( grep { /swappiness/i } @generalrec ) { $gen_score -= 20; } - + my $other_mem = get_other_process_memory() // 0; - if ( ( $physical_memory // 0 ) > 0 && ($other_mem / $physical_memory) > 0.3 ) { + if ( ( $physical_memory // 0 ) > 0 + && ( $other_mem / $physical_memory ) > 0.3 ) + { $gen_score -= 20; } $gen_score = 0 if $gen_score < 0; # 2. Storage Engine KPI my $storage_score = 100; - + my $read_eff = $mycalc{'pct_read_efficiency'} // 100; - if ($read_eff < 95) { + if ( $read_eff < 95 ) { $storage_score -= 25; } - + my $temp_disk = $mycalc{'pct_temp_disk'} // 0; - if ($temp_disk > 25) { + if ( $temp_disk > 25 ) { $storage_score -= 25; } - + my $table_hit = $mycalc{'table_cache_hit_rate'} // 100; - if ($table_hit < 50) { + if ( $table_hit < 50 ) { $storage_score -= 25; } - - my $redo_adjust = grep { /innodb_log_file_size/i || /innodb_redo_log_capacity/i } @adjvars; + + my $redo_adjust = + grep { /innodb_log_file_size/i || /innodb_redo_log_capacity/i } @adjvars; if ($redo_adjust) { $storage_score -= 25; } @@ -1070,18 +1078,21 @@ sub calculate_sectional_health_scores { # 4. Replication & HA KPI my $repl_score = 100; - if ( %myrepl ) { - my $lag = $myrepl{'Seconds_Behind_Source'} // $myrepl{'Seconds_Behind_Replica'}; - if (defined $lag && $lag > 60) { + if (%myrepl) { + my $lag = $myrepl{'Seconds_Behind_Source'} + // $myrepl{'Seconds_Behind_Replica'}; + if ( defined $lag && $lag > 60 ) { $repl_score -= 40; } - my $io_run = $myrepl{'Replica_IO_Running'} // $myrepl{'Slave_IO_Running'} // 'Yes'; - my $sql_run = $myrepl{'Replica_SQL_Running'} // $myrepl{'Slave_SQL_Running'} // 'Yes'; - if ($io_run =~ /No/i || $sql_run =~ /No/i) { + my $io_run = $myrepl{'Replica_IO_Running'} + // $myrepl{'Slave_IO_Running'} // 'Yes'; + my $sql_run = $myrepl{'Replica_SQL_Running'} + // $myrepl{'Slave_SQL_Running'} // 'Yes'; + if ( $io_run =~ /No/i || $sql_run =~ /No/i ) { $repl_score -= 35; } my $gtid = $myvar{'gtid_mode'} // 'OFF'; - if ($gtid =~ /OFF/i) { + if ( $gtid =~ /OFF/i ) { $repl_score -= 25; } } @@ -1101,9 +1112,10 @@ sub calculate_sectional_health_scores { # Calculate Resource Saturation Heatmap my $cpu_sat = 0; if (@load) { - my $load_1 = $load[0] // 0; - my $cpu_cores = $mycalc{'physical_cpu_cores'} // $mycalc{'cpu_cores'} // 1; - $cpu_sat = ($cpu_cores > 0) ? ($load_1 / $cpu_cores) * 100 : 0; + my $load_1 = $load[0] // 0; + my $cpu_cores = $mycalc{'physical_cpu_cores'} // $mycalc{'cpu_cores'} + // 1; + $cpu_sat = ( $cpu_cores > 0 ) ? ( $load_1 / $cpu_cores ) * 100 : 0; $cpu_sat = 100 if $cpu_sat > 100; } @@ -1115,11 +1127,14 @@ sub calculate_sectional_health_scores { $conn_sat =~ s/%//g; $conn_sat = 100 if $conn_sat > 100; - my $read_eff_val = $mycalc{'pct_read_efficiency'} // 100; - my $disk_read_pressure = 100 - $read_eff_val; + my $read_eff_val = $mycalc{'pct_read_efficiency'} // 100; + my $disk_read_pressure = 100 - $read_eff_val; my $scaled_read_pressure = $disk_read_pressure * 20; - my $temp_disk_val = $mycalc{'pct_temp_disk'} // 0; - my $io_sat = ($scaled_read_pressure > $temp_disk_val) ? $scaled_read_pressure : $temp_disk_val; + my $temp_disk_val = $mycalc{'pct_temp_disk'} // 0; + my $io_sat = + ( $scaled_read_pressure > $temp_disk_val ) + ? $scaled_read_pressure + : $temp_disk_val; $io_sat = 100 if $io_sat > 100; $result{'ResourceSaturation'}{'CPU'} = int($cpu_sat); @@ -1128,36 +1143,47 @@ sub calculate_sectional_health_scores { $result{'ResourceSaturation'}{'IO'} = int($io_sat); # Calculate Throughput Efficiency Index - my $qps_val = ( ( $mystat{'Uptime'} || 0 ) > 0 ) ? ( $mystat{'Questions'} || 0 ) / $mystat{'Uptime'} : 0; - my $logical_reads_sec = ( $uptime > 0 ) ? ( $mystat{'Innodb_buffer_pool_read_requests'} // 0 ) / $uptime : 0; - my $tei = ( $logical_reads_sec > 0 ) ? ( $qps_val / $logical_reads_sec ) : 0; - - $result{'ThroughputEfficiency'}{'QPS'} = sprintf("%.3f", $qps_val) + 0; - $result{'ThroughputEfficiency'}{'LogicalReadsSec'} = sprintf("%.3f", $logical_reads_sec) + 0; - $result{'ThroughputEfficiency'}{'Index'} = sprintf("%.5f", $tei) + 0; + my $qps_val = + ( ( $mystat{'Uptime'} || 0 ) > 0 ) + ? ( $mystat{'Questions'} || 0 ) / $mystat{'Uptime'} + : 0; + my $logical_reads_sec = + ( $uptime > 0 ) + ? ( $mystat{'Innodb_buffer_pool_read_requests'} // 0 ) / $uptime + : 0; + my $tei = + ( $logical_reads_sec > 0 ) ? ( $qps_val / $logical_reads_sec ) : 0; + + $result{'ThroughputEfficiency'}{'QPS'} = sprintf( "%.3f", $qps_val ) + 0; + $result{'ThroughputEfficiency'}{'LogicalReadsSec'} = + sprintf( "%.3f", $logical_reads_sec ) + 0; + $result{'ThroughputEfficiency'}{'Index'} = sprintf( "%.5f", $tei ) + 0; } sub get_top_findings { my $list_ref = shift; - my @items = (); + my @items = (); foreach my $it (@$list_ref) { push @items, format_recommendation_item($it); } - + my @scored = (); foreach my $item (@items) { my $score = 1; - if ($item =~ /(dangerously high|insecure|vulnerability|CVE|not running|aborted|unencrypted|anonymous|remove|risk|critical|warning|incorrect|mismatch)/i) { + if ( $item =~ +/(dangerously high|insecure|vulnerability|CVE|not running|aborted|unencrypted|anonymous|remove|risk|critical|warning|incorrect|mismatch)/i + ) + { $score = 2; } push @scored, { text => $item, score => $score }; } - + my @sorted = sort { $b->{score} <=> $a->{score} } @scored; - + my @res = (); - for (my $i = 0; $i < 3 && $i < @sorted; $i++) { - my $badge = ($sorted[$i]->{score} == 2) ? 'Critical' : 'Finding'; + for ( my $i = 0 ; $i < 3 && $i < @sorted ; $i++ ) { + my $badge = ( $sorted[$i]->{score} == 2 ) ? 'Critical' : 'Finding'; push @res, { text => $sorted[$i]->{text}, badge => $badge }; } return @res; @@ -1589,13 +1615,15 @@ sub infoprintcmd { sub subheaderprint { my $name = $_[0]; - my $now = eval { require Time::HiRes; Time::HiRes::time(); } || time(); + my $now = eval { require Time::HiRes; Time::HiRes::time(); } || time(); if ( defined $current_section_name && $opt{'verbose'} ) { my $elapsed = $now - $current_section_start; push @section_timings, [ $current_section_name, $elapsed ]; - infoprint sprintf("%s execution time: %.3fs", $current_section_name, $elapsed); + infoprint + sprintf( "%s execution time: %.3fs", $current_section_name, + $elapsed ); } - $current_section_name = $name; + $current_section_name = $name; $current_section_start = $now; my $tln = 100; @@ -1611,7 +1639,9 @@ sub stop_section_timing { if ( defined $current_section_name && $opt{'verbose'} ) { my $elapsed = $now - $current_section_start; push @section_timings, [ $current_section_name, $elapsed ]; - infoprint sprintf("%s execution time: %.3fs", $current_section_name, $elapsed); + infoprint + sprintf( "%s execution time: %.3fs", $current_section_name, + $elapsed ); } $current_section_name = undef; } @@ -1621,7 +1651,8 @@ sub print_execution_timings { stop_section_timing(); - my $total_now = eval { require Time::HiRes; Time::HiRes::time(); } || time(); + my $total_now = + eval { require Time::HiRes; Time::HiRes::time(); } || time(); my $total_elapsed = $total_now - $tuner_start_time; subheaderprint "Execution Times"; @@ -1641,13 +1672,18 @@ sub print_audit_snapshot_summary { $host .= ":$opt{'port'}"; } - my $db_user = select_one("SELECT CURRENT_USER()") || $opt{'user'} || 'unknown'; + my $db_user = + select_one("SELECT CURRENT_USER()") || $opt{'user'} || 'unknown'; - my $ram_str = defined($physical_memory) ? hr_bytes($physical_memory) : 'unknown'; + my $ram_str = + defined($physical_memory) ? hr_bytes($physical_memory) : 'unknown'; my $swap_str = defined($swap_memory) ? hr_bytes($swap_memory) : 'unknown'; my $db_ver = $myvar{'version'} // 'unknown'; - my $uptime_str = defined($mystat{'Uptime'}) ? pretty_uptime($mystat{'Uptime'}) : 'unknown'; + my $uptime_str = + defined( $mystat{'Uptime'} ) + ? pretty_uptime( $mystat{'Uptime'} ) + : 'unknown'; infoprint "MySQLTuner Version : $tunerversion"; infoprint "Server Connection : $host"; @@ -4225,7 +4261,9 @@ sub get_load_average { } } my $uptime_output = execute_system_command("uptime") // ''; - if ( $uptime_output =~ /load average(?:s)?:?\s+([\d.]+),?\s+([\d.]+),?\s+([\d.]+)/i ) { + if ( $uptime_output =~ + /load average(?:s)?:?\s+([\d.]+),?\s+([\d.]+),?\s+([\d.]+)/i ) + { return ( $1, $2, $3 ); } return (); @@ -6658,16 +6696,16 @@ sub calculations { $mycalc{'query_cache_efficiency'} = 0; } elsif ( mysql_version_ge(4) ) { - if ( ( $mystat{'Com_select'} || 0 ) + ( $mystat{'Qcache_hits'} || 0 ) > - 0 ) - { - $mycalc{'query_cache_efficiency'} = sprintf( - "%.1f", - ( - $mystat{'Qcache_hits'} / - ( $mystat{'Com_select'} + $mystat{'Qcache_hits'} ) - ) * 100 - ); + + # MDEV-4981: In MariaDB, Com_select includes query cache hits (Qcache_hits) + my $total_selects = + $is_mariadb + ? ( $mystat{'Com_select'} || 0 ) + : ( + ( $mystat{'Com_select'} || 0 ) + ( $mystat{'Qcache_hits'} || 0 ) ); + if ( $total_selects > 0 ) { + $mycalc{'query_cache_efficiency'} = sprintf( "%.1f", + ( ( $mystat{'Qcache_hits'} || 0 ) / $total_selects ) * 100 ); } else { $mycalc{'query_cache_efficiency'} = 0; @@ -7195,12 +7233,21 @@ sub mysql_stats { "Query cache cannot be analyzed: no SELECT statements executed"; } else { + # MDEV-4981: In MariaDB, Com_select includes query cache hits (Qcache_hits) + my $is_mariadb = ( $myvar{'version'} // '' ) =~ /mariadb/i + || ( $myvar{'version_comment'} // '' ) =~ /mariadb/i; + my $total_selects = + $is_mariadb + ? ( $mystat{'Com_select'} || 0 ) + : ( + ( $mystat{'Com_select'} || 0 ) + ( $mystat{'Qcache_hits'} || 0 ) ); + if ( $mycalc{'query_cache_efficiency'} < 20 ) { badprint "Query cache efficiency: $mycalc{'query_cache_efficiency'}% (" . hr_num( $mystat{'Qcache_hits'} ) . " cached / " - . hr_num( $mystat{'Qcache_hits'} + $mystat{'Com_select'} ) + . hr_num($total_selects) . " selects)"; badprint "Query cache may be disabled by default due to mutex contention."; @@ -7212,7 +7259,7 @@ sub mysql_stats { "Query cache efficiency: $mycalc{'query_cache_efficiency'}% (" . hr_num( $mystat{'Qcache_hits'} ) . " cached / " - . hr_num( $mystat{'Qcache_hits'} + $mystat{'Com_select'} ) + . hr_num($total_selects) . " selects)"; if ( $mycalc{'query_cache_prunes_per_day'} > 98 ) { badprint @@ -11754,7 +11801,7 @@ sub historical_comparison { ( $old->{'Stats'}{'QPS'} > 0 ) ? ( $diff / $old->{'Stats'}{'QPS'} ) * 100 : 0; - my $trend = ( $diff >= 0 ) ? "+" : ""; + my $trend = ( $diff >= 0 ) ? "+" : ""; my $qps_trend = sprintf( "QPS Trend: %.2f -> %.2f (%s%.2f%%)", $old->{'Stats'}{'QPS'}, @@ -11767,8 +11814,8 @@ sub historical_comparison { # Compare Health Score if ( defined $result{'HealthScore'} && defined $old->{'HealthScore'} ) { - my $diff = $result{'HealthScore'} - $old->{'HealthScore'}; - my $trend = ( $diff > 0 ) ? "+" : ""; + my $diff = $result{'HealthScore'} - $old->{'HealthScore'}; + my $trend = ( $diff > 0 ) ? "+" : ""; my $score_trend = sprintf( "Health Score Trend: %d -> %d (%s%d)", $old->{'HealthScore'}, $result{'HealthScore'}, $trend, $diff ); infoprint $score_trend; @@ -11781,7 +11828,8 @@ sub historical_comparison { { my $diff = $result{'Stats'}{'Total Data Size'} - $old->{'Stats'}{'Total Data Size'}; - my $size_trend = "Data Growth: " + my $size_trend = + "Data Growth: " . hr_bytes( $old->{'Stats'}{'Total Data Size'} ) . " -> " . hr_bytes( $result{'Stats'}{'Total Data Size'} ) . " (" . hr_bytes($diff) . ")"; @@ -11790,15 +11838,19 @@ sub historical_comparison { } # Compare sectional scores - foreach my $sec ('General', 'Storage', 'Security', 'Replication', 'Modeling') { - if (defined $result{'SectionalHealthScore'}{$sec} && defined $old->{'SectionalHealthScore'}{$sec}) { - my $diff = $result{'SectionalHealthScore'}{$sec} - $old->{'SectionalHealthScore'}{$sec}; - my $trend = ($diff > 0) ? "+" : ""; - $result{'Trends'}{'Sectional'}{$sec} = sprintf("%d -> %d (%s%d)", + foreach + my $sec ( 'General', 'Storage', 'Security', 'Replication', 'Modeling' ) + { + if ( defined $result{'SectionalHealthScore'}{$sec} + && defined $old->{'SectionalHealthScore'}{$sec} ) + { + my $diff = $result{'SectionalHealthScore'}{$sec} - + $old->{'SectionalHealthScore'}{$sec}; + my $trend = ( $diff > 0 ) ? "+" : ""; + $result{'Trends'}{'Sectional'}{$sec} = sprintf( "%d -> %d (%s%d)", $old->{'SectionalHealthScore'}{$sec}, $result{'SectionalHealthScore'}{$sec}, - $trend, $diff - ); + $trend, $diff ); } } @@ -12914,63 +12966,82 @@ sub dump_result { join( "\n", map { escape_html($_) } @raw_output_lines ); # Phase 13: Calculate Sectional Health Score variables - my $kpi_gen = $result{'SectionalHealthScore'}{'General'} // 100; - my $kpi_stor = $result{'SectionalHealthScore'}{'Storage'} // 100; - my $kpi_sec = $result{'SectionalHealthScore'}{'Security'} // 100; + my $kpi_gen = $result{'SectionalHealthScore'}{'General'} // 100; + my $kpi_stor = $result{'SectionalHealthScore'}{'Storage'} // 100; + my $kpi_sec = $result{'SectionalHealthScore'}{'Security'} // 100; my $kpi_repl = $result{'SectionalHealthScore'}{'Replication'} // 100; - my $kpi_mode = $result{'SectionalHealthScore'}{'Modeling'} // 100; + my $kpi_mode = $result{'SectionalHealthScore'}{'Modeling'} // 100; # Prioritized Top Findings lists - my @general_top = get_top_findings(\@generalrec); - my @storage_top = get_top_findings(\@adjvars); - my @security_top = get_top_findings(\@secrec); - my @repl_recs = grep { /(replica|sla[v]e|gtid|replication|binlog|relay)/i } @generalrec; - my @replication_top = get_top_findings(\@repl_recs); - my @modeling_top = get_top_findings(\@modeling); + my @general_top = get_top_findings( \@generalrec ); + my @storage_top = get_top_findings( \@adjvars ); + my @security_top = get_top_findings( \@secrec ); + my @repl_recs = + grep { /(replica|sla[v]e|gtid|replication|binlog|relay)/i } + @generalrec; + my @replication_top = get_top_findings( \@repl_recs ); + my @modeling_top = get_top_findings( \@modeling ); my $format_top_findings = sub { - my ($title, $findings_ref) = @_; + my ( $title, $findings_ref ) = @_; my @findings = @$findings_ref; - if (!@findings) { - return "
    " - . "

    $title🟢 Optimal

    " - . "

    No critical issues or recommendations detected in this area.

    " - . "
    "; + if ( !@findings ) { + return +"
    " + . "

    $title🟢 Optimal

    " + . "

    No critical issues or recommendations detected in this area.

    " + . "
    "; } my $items_html = ''; foreach my $f (@findings) { - my $badge_color = ($f->{badge} eq 'Critical') ? 'text-rose-400 bg-rose-500/10 border-rose-500/20' : 'text-amber-400 bg-amber-500/10 border-amber-500/20'; - $items_html .= "
  • " - . "" . $f->{badge} . "" - . "" . escape_html($f->{text}) . "" - . "
  • "; - } - return "
    " - . "

    $title⚠️ Action Required

    " - . "
      $items_html
    " - . "
    "; + my $badge_color = + ( $f->{badge} eq 'Critical' ) + ? 'text-rose-400 bg-rose-500/10 border-rose-500/20' + : 'text-amber-400 bg-amber-500/10 border-amber-500/20'; + $items_html .= +"
  • " + . "" + . $f->{badge} + . "" + . "" + . escape_html( $f->{text} ) + . "" . "
  • "; + } + return +"
    " + . "

    $title⚠️ Action Required

    " + . "
      $items_html
    " + . "
    "; }; - my $gen_findings_html = $format_top_findings->('General Stats', \@general_top); - my $store_findings_html = $format_top_findings->('Storage & Performance', \@storage_top); - my $sec_findings_html = $format_top_findings->('Security & Compliance', \@security_top); - my $repl_findings_html = $format_top_findings->('Replication & HA', \@replication_top); - my $model_findings_html = $format_top_findings->('SQL Modeling', \@modeling_top); + my $gen_findings_html = + $format_top_findings->( 'General Stats', \@general_top ); + my $store_findings_html = + $format_top_findings->( 'Storage & Performance', \@storage_top ); + my $sec_findings_html = + $format_top_findings->( 'Security & Compliance', \@security_top ); + my $repl_findings_html = + $format_top_findings->( 'Replication & HA', \@replication_top ); + my $model_findings_html = + $format_top_findings->( 'SQL Modeling', \@modeling_top ); # Throughput Efficiency Index my $tei_qps = $result{'ThroughputEfficiency'}{'QPS'} // 0.0; - my $tei_reads = $result{'ThroughputEfficiency'}{'LogicalReadsSec'} // 0.0; + my $tei_reads = $result{'ThroughputEfficiency'}{'LogicalReadsSec'} + // 0.0; my $tei_index = $result{'ThroughputEfficiency'}{'Index'} // 0.00000; # Resource Saturation Heatmap - my $sat_cpu = $result{'ResourceSaturation'}{'CPU'} // 0; - my $sat_mem = $result{'ResourceSaturation'}{'Memory'} // 0; + my $sat_cpu = $result{'ResourceSaturation'}{'CPU'} // 0; + my $sat_mem = $result{'ResourceSaturation'}{'Memory'} // 0; my $sat_conn = $result{'ResourceSaturation'}{'Connections'} // 0; - my $sat_io = $result{'ResourceSaturation'}{'IO'} // 0; + my $sat_io = $result{'ResourceSaturation'}{'IO'} // 0; my $get_sat_color = sub { my $val = shift; - return $val > 85 ? 'bg-rose-500' : ($val > 60 ? 'bg-amber-400' : 'bg-emerald-500'); + return $val > 85 + ? 'bg-rose-500' + : ( $val > 60 ? 'bg-amber-400' : 'bg-emerald-500' ); }; my $cpu_color = $get_sat_color->($sat_cpu); my $mem_color = $get_sat_color->($sat_mem); @@ -12979,14 +13050,17 @@ sub dump_result { # Historical Trend Deltas my $historical_deltas_html = ''; - if (defined $result{'Trends'}) { - my $qps_delta = $result{'Trends'}{'QPS'} // 'N/A'; - my $score_delta = $result{'Trends'}{'HealthScore'} // 'N/A'; + if ( defined $result{'Trends'} ) { + my $qps_delta = $result{'Trends'}{'QPS'} // 'N/A'; + my $score_delta = $result{'Trends'}{'HealthScore'} // 'N/A'; my $size_delta = $result{'Trends'}{'TotalDataSize'} // 'N/A'; my $sec_deltas = ''; - foreach my $sec ('General', 'Storage', 'Security', 'Replication', 'Modeling') { + foreach my $sec ( 'General', 'Storage', 'Security', 'Replication', + 'Modeling' ) + { my $d = $result{'Trends'}{'Sectional'}{$sec} // 'N/A'; - $sec_deltas .= "
    $sec: $d
    "; + $sec_deltas .= +"
    $sec: $d
    "; } $historical_deltas_html = <<"HTML";
    @@ -13662,7 +13736,8 @@ sub dump_csv_files { # BEGIN 'MAIN' # --------------------------------------------------------------------------- if ( !caller ) { - $tuner_start_time = eval { require Time::HiRes; Time::HiRes::time(); } || time(); + $tuner_start_time = + eval { require Time::HiRes; Time::HiRes::time(); } || time(); parse_cli_args; # Parse CLI arguments setup_environment; # Initialize variables and handle early exits headerprint; # Header Print @@ -13673,14 +13748,14 @@ sub dump_csv_files { debugprint "MySQL FINAL Client : $mysqlcmd $mysqllogin"; debugprint "MySQL Admin FINAL Client : $mysqladmincmd $mysqllogin"; - os_setup; # Set up some OS variables - get_all_vars; # Toss variables/status into hashes + os_setup; # Set up some OS variables + get_all_vars; # Toss variables/status into hashes print_audit_snapshot_summary; # Summary of the audit snapshot - mysql_cloud_discovery; # Auto-discover cloud environment - get_tuning_info; # Get information about the tuning connection - calculations; # Calculate everything we need - check_architecture; # Suggest 64-bit upgrade - check_storage_engines; # Show enabled storage engines + mysql_cloud_discovery; # Auto-discover cloud environment + get_tuning_info; # Get information about the tuning connection + calculations; # Calculate everything we need + check_architecture; # Suggest 64-bit upgrade + check_storage_engines; # Show enabled storage engines if ( $opt{'feature'} ) { subheaderprint "See FEATURES.md for more information"; @@ -13689,7 +13764,7 @@ sub dump_csv_files { subheaderprint "Running feature: $feature"; $feature->(); } - dump_csv_files; # dump csv files + dump_csv_files; # dump csv files make_recommendations; print_execution_timings(); goodprint "Terminated successfully"; @@ -13719,7 +13794,7 @@ sub dump_csv_files { $section->(); } - dump_csv_files; # dump csv files + dump_csv_files; # dump csv files make_recommendations; # Make recommendations based on stats dump_result; # Dump result if debug is on print_execution_timings(); diff --git a/releases/v2.9.0.md b/releases/v2.9.0.md index 2aeb092b9..a859f1732 100644 --- a/releases/v2.9.0.md +++ b/releases/v2.9.0.md @@ -14,9 +14,11 @@ - feat(report): move dump_csv_files execution step to immediately before make_recommendations. - feat(report): print an environment audit snapshot summary (server, user, RAM, swap, versions, uptime) right after get_all_vars. - feat(report): support historical comparison of database diagnostics and performance metrics over time. +- fix(main): calculate query cache efficiency using Com_select on MariaDB, where Com_select includes query cache hits (MDEV-4981). - fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. - fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables. - fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks. +- test(lab): add unit tests for query cache efficiency logic on MySQL and MariaDB in tests/issue_927.t. - test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t. - test(versions): add unit tests for version caching and comparisons in tests/unit_versions.t. ``` diff --git a/tests/issue_927.t b/tests/issue_927.t new file mode 100644 index 000000000..27d822440 --- /dev/null +++ b/tests/issue_927.t @@ -0,0 +1,240 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; + +use Test::More tests => 2; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +# Load mysqltuner.pl as a library +my $script_dir = dirname( abs_path(__FILE__) ); +my $script = + abs_path( File::Spec->catfile( $script_dir, '..', 'mysqltuner.pl' ) ); +require $script; +require './tests/MySQLTuner/TestHelper.pm'; + +# Mock global variables +our %myvar; +our %mystat; +our %mycalc; +our $physical_memory; +our $swap_memory; +our @generalrec; +our @adjvars; + +# Setup common mocks globally +no warnings 'redefine'; +no warnings 'uninitialized'; +*main::debugprint = sub { }; +*main::is_int = sub { return $_[0] && $_[0] =~ /^\d+$/ }; +*main::human_size = sub { return $_[0] }; +*main::hr_bytes = sub { return $_[0] }; +*main::hr_bytes_rnd = sub { return $_[0] }; +*main::hr_num = sub { + my $val = shift; + + # simple formatting for tests + 1 while $val =~ s/(\d+)(\d{3})/$1,$2/; + return $val; +}; +*main::percentage = sub { return 0 }; +*main::select_one = sub { return 0 }; +*main::select_array = sub { return () }; +*main::mysql_version_ge = sub { return 1 }; +*main::mysql_version_le = sub { return 0 }; +*main::mysql_version_eq = sub { return 0 }; +*main::execute_system_command = sub { return "" }; +*main::get_pf_memory = sub { return 0 }; +*main::get_gcache_memory = sub { return 0 }; +*main::is_remote = sub () { return 0 }; +*main::mysql_cloud_discovery = sub { return "none" }; +*main::pretty_uptime = sub { return "1 day" }; +*main::get_other_process_memory = sub { return 0 }; + +subtest 'Query cache efficiency on standard MySQL' => sub { + $main::physical_memory = 32 * 1024 * 1024 * 1024; + $main::swap_memory = 4 * 1024 * 1024 * 1024; + + %main::myvar = ( + 'version' => '5.7.35', + 'version_comment' => 'MySQL Community Server', + 'read_buffer_size' => 1024, + 'read_rnd_buffer_size' => 1024, + 'sort_buffer_size' => 1024, + 'thread_stack' => 1024, + 'join_buffer_size' => 1024, + 'binlog_cache_size' => 1024, + 'tmp_table_size' => 1024, + 'max_heap_table_size' => 1024, + 'max_connections' => 10, + 'key_buffer_size' => 5000, + 'innodb_buffer_pool_size' => 10000, + 'innodb_additional_mem_pool_size' => 1024, + 'innodb_log_buffer_size' => 1024, + 'query_cache_size' => 64 * 1024 * 1024, + 'query_cache_type' => 'ON', + 'aria_pagecache_buffer_size' => 1024, + 'long_query_time' => 10, + 'log_bin' => 'OFF', + 'have_innodb' => 'YES', + 'open_files_limit' => 1024, + 'thread_cache_size' => 8, + 'concurrent_insert' => 'AUTO', + ); + %main::mystat = ( + 'Questions' => 1500, + 'Max_used_connections' => 5, + 'Uptime' => 86400, + 'Qcache_free_memory' => 32 * 1024 * 1024, + 'Qcache_hits' => 1000, + 'Com_select' => 1500, + 'Qcache_lowmem_prunes' => 0, + 'Connections' => 100, + 'Aborted_connects' => 0, + 'Key_read_requests' => 100, + 'Key_reads' => 0, + 'Key_write_requests' => 100, + 'Key_writes' => 0, + 'Slow_queries' => 0, + 'Key_blocks_unused' => 100, + 'Table_locks_immediate' => 100, + 'Table_locks_waited' => 0, + 'Created_tmp_tables' => 10, + 'Opened_tables' => 10, + 'Open_tables' => 10, + 'Threads_cached' => 5, + 'Bytes_sent' => 1000, + 'Bytes_received' => 1000, + 'Threads_created' => 2, + ); + %main::mycalc = (); + @main::generalrec = (); + @main::adjvars = (); + + eval { main::calculations(); }; + ok( !$@, "calculations completed without crash" ); + is( $main::mycalc{'query_cache_efficiency'}, "40.0", +"MySQL efficiency calculation is Qcache_hits / (Com_select + Qcache_hits) = 40%" + ); + + my @good_prints; + my @bad_prints; + my @info_prints; + local *main::goodprint = sub { push @good_prints, $_[0] }; + local *main::badprint = sub { push @bad_prints, $_[0] }; + local *main::infoprint = sub { push @info_prints, $_[0] }; + local *main::subheaderprint = sub { }; + + eval { main::mysql_stats(); }; + ok( !$@, "mysql_stats completed without crash" ); + + my $found_msg = ""; + for my $msg ( @good_prints, @bad_prints ) { + if ( $msg =~ /Query cache efficiency/ ) { + $found_msg = $msg; + last; + } + } + + like( + $found_msg, + qr/Query cache efficiency: 40\.0% \(1,000 cached \/ 2,500 selects\)/, +"Display text shows correct MySQL hit ratio and total selects (2,500 selects)" + ); +}; + +subtest 'Query cache efficiency on MariaDB' => sub { + $main::physical_memory = 32 * 1024 * 1024 * 1024; + $main::swap_memory = 4 * 1024 * 1024 * 1024; + + %main::myvar = ( + 'version' => '10.11.14-MariaDB', + 'version_comment' => 'MariaDB Server', + 'read_buffer_size' => 1024, + 'read_rnd_buffer_size' => 1024, + 'sort_buffer_size' => 1024, + 'thread_stack' => 1024, + 'join_buffer_size' => 1024, + 'binlog_cache_size' => 1024, + 'tmp_table_size' => 1024, + 'max_heap_table_size' => 1024, + 'max_connections' => 10, + 'key_buffer_size' => 5000, + 'innodb_buffer_pool_size' => 10000, + 'innodb_additional_mem_pool_size' => 1024, + 'innodb_log_buffer_size' => 1024, + 'query_cache_size' => 64 * 1024 * 1024, + 'query_cache_type' => 'ON', + 'aria_pagecache_buffer_size' => 1024, + 'long_query_time' => 10, + 'log_bin' => 'OFF', + 'have_innodb' => 'YES', + 'open_files_limit' => 1024, + 'thread_cache_size' => 8, + 'concurrent_insert' => 'AUTO', + ); + %main::mystat = ( + 'Questions' => 1500, + 'Max_used_connections' => 5, + 'Uptime' => 86400, + 'Qcache_free_memory' => 32 * 1024 * 1024, + 'Qcache_hits' => 1000, + 'Com_select' => 1500, + 'Qcache_lowmem_prunes' => 0, + 'Connections' => 100, + 'Aborted_connects' => 0, + 'Key_read_requests' => 100, + 'Key_reads' => 0, + 'Key_write_requests' => 100, + 'Key_writes' => 0, + 'Slow_queries' => 0, + 'Key_blocks_unused' => 100, + 'Table_locks_immediate' => 100, + 'Table_locks_waited' => 0, + 'Created_tmp_tables' => 10, + 'Opened_tables' => 10, + 'Open_tables' => 10, + 'Threads_cached' => 5, + 'Bytes_sent' => 1000, + 'Bytes_received' => 1000, + 'Threads_created' => 2, + ); + %main::mycalc = (); + @main::generalrec = (); + @main::adjvars = (); + + eval { main::calculations(); }; + ok( !$@, "calculations completed without crash" ); + is( $main::mycalc{'query_cache_efficiency'}, + "66.7", + "MariaDB efficiency calculation is Qcache_hits / Com_select = 66.7%" ); + + my @good_prints; + my @bad_prints; + my @info_prints; + local *main::goodprint = sub { push @good_prints, $_[0] }; + local *main::badprint = sub { push @bad_prints, $_[0] }; + local *main::infoprint = sub { push @info_prints, $_[0] }; + local *main::subheaderprint = sub { }; + + eval { main::mysql_stats(); }; + ok( !$@, "mysql_stats completed without crash" ); + + my $found_msg = ""; + for my $msg ( @good_prints, @bad_prints ) { + if ( $msg =~ /Query cache efficiency/ ) { + $found_msg = $msg; + last; + } + } + + like( + $found_msg, + qr/Query cache efficiency: 66\.7% \(1,000 cached \/ 1,500 selects\)/, +"Display text shows correct MariaDB hit ratio and total selects without double-counting (1,500 selects)" + ); +}; + +1; From 4575b0af858d6d4f7516c445da451bf1ccb9d5c4 Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Tue, 23 Jun 2026 18:46:55 +0200 Subject: [PATCH 06/16] docs(metadata): remove timestamp from doc-sync generated files --- build/doc_sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/doc_sync.py b/build/doc_sync.py index d5b0af482..924feac36 100644 --- a/build/doc_sync.py +++ b/build/doc_sync.py @@ -58,7 +58,7 @@ def generate_readme(): output.append("\n") - output.append("---\n*Generated automatically by `/doc-sync` on " + datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "*") + output.append("---\n*Generated automatically by `/doc-sync`*") with open(README_PATH, 'w') as f: f.write("\n".join(output)) From a1be73ce06dbb6514fa919d62abb1e91c37cb198 Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Tue, 23 Jun 2026 19:05:11 +0200 Subject: [PATCH 07/16] feat(metadata): fix test badge and update version references in READMEs --- .agent/README.md | 2 +- README.fr.md | 4 ++-- README.it.md | 4 ++-- README.md | 6 +++--- README.ru.md | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.agent/README.md b/.agent/README.md index 3e748ae64..06b3cd57c 100644 --- a/.agent/README.md +++ b/.agent/README.md @@ -48,4 +48,4 @@ This directory contains the project's technical constitution, specialized skills --- -*Generated automatically by `/doc-sync`* +*Generated automatically by `/doc-sync`* \ No newline at end of file diff --git a/README.fr.md b/README.fr.md index 56fd51363..7448532a1 100644 --- a/README.fr.md +++ b/README.fr.md @@ -3,7 +3,7 @@ [!["Offrez-nous un café"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) [![État du projet](https://opensource.box.com/badges/active.svg)](https://opensource.box.com/badges) -[![État des tests](https://github.com/jmrenouard/MySQLTuner-perl/workflows/Test/badge.svg)](https://github.com/jmrenouard/MySQLTuner-perl/actions) +[![État des tests](https://github.com/jmrenouard/MySQLTuner-perl/actions/workflows/pull_request.yml/badge.svg)](https://github.com/jmrenouard/MySQLTuner-perl/actions) [![Temps moyen de résolution d'un problème](https://isitmaintained.com/badge/resolution/jmrenouard/MySQLTuner-perl.svg)](https://isitmaintained.com/project/jmrenouard/MySQLTuner-perl "Temps moyen de résolution d'un problème") [![Pourcentage de problèmes ouverts](https://isitmaintained.com/badge/open/jmrenouard/MySQLTuner-perl.svg)](https://isitmaintained.com/project/jmrenouard/MySQLTuner-perl "Pourcentage de problèmes encore ouverts") [![Licence GPL](https://badges.frapsoft.com/os/gpl/gpl.png?v=103)](https://opensource.org/licenses/GPL-3.0/) @@ -217,7 +217,7 @@ docker run --rm -it jmrenouard/mysqltuner --host --user --user --user --user Date: Tue, 23 Jun 2026 20:06:14 +0200 Subject: [PATCH 08/16] fix(main): address PR #931 code review feedback and enhance test validations --- Changelog | 13 +- .../verbose_execution_timings.md | 2 +- mysqltuner.pl | 113 +++++++++++++++--- releases/v2.9.0.md | 13 +- tests/{issue_927.t => test_issue_927.t} | 0 tests/unit_phase13_kpis.t | 15 +-- tests/unit_versions.t | 50 +++++++- 7 files changed, 164 insertions(+), 42 deletions(-) rename tests/{issue_927.t => test_issue_927.t} (100%) diff --git a/Changelog b/Changelog index 0b2559114..ddfb2df24 100644 --- a/Changelog +++ b/Changelog @@ -9,13 +9,20 @@ - feat(report): move dump_csv_files execution step to immediately before make_recommendations. - feat(report): print an environment audit snapshot summary (server, user, RAM, swap, versions, uptime) right after get_all_vars. - feat(report): support historical comparison of database diagnostics and performance metrics over time. +- fix(cli): add mutually exclusive guard for json and yaml options. - fix(main): calculate query cache efficiency using Com_select on MariaDB, where Com_select includes query cache hits (MDEV-4981). -- fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. +- fix(main): calculate health score early in historical comparison to ensure scores exist for trend analysis. +- fix(main): format YAML null as tilde (~) and multiline values using literal block style. - fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables. - fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks. -- test(lab): add unit tests for query cache efficiency logic on MySQL and MariaDB in tests/issue_927.t. +- fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. +- fix(main): sanitize and redact sensitive credentials from json/yaml output exports. +- fix(main): skip local /proc/loadavg read during remote database audits. +- fix(report): flush console output trace when opening raw log files late in dumpdir mode. +- test(lab): add unit tests for query cache efficiency logic on MySQL and MariaDB in tests/test_issue_927.t (renamed from tests/issue_927.t). +- test(lab): utilize isolated tempfile for mock json to avoid race conditions in tests/unit_phase13_kpis.t. - test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t. -- test(versions): add unit tests for version caching and comparisons in tests/unit_versions.t. +- test(versions): add unit tests for version caching and comparisons, resolve redundant warnings, and use tempfile in tests/unit_versions.t. 2.8.45 2026-06-04 diff --git a/documentation/specifications/verbose_execution_timings.md b/documentation/specifications/verbose_execution_timings.md index 66da19150..2c3ac4fba 100644 --- a/documentation/specifications/verbose_execution_timings.md +++ b/documentation/specifications/verbose_execution_timings.md @@ -11,7 +11,7 @@ Add execution timing information for each section and the total execution time a - At the end of each printed block (e.g., after `MyISAM Metrics`), its individual execution time is printed. - Before the final `✔ Terminated successfully` message, a summary block (`Execution Times`) listing all section names with their durations and the total execution time is printed. - **Example Console Output**: - ``` + ```text -------- MyISAM Metrics ---------------------------------------------------------------------------- ... [--] MyISAM Metrics execution time: 0.123s diff --git a/mysqltuner.pl b/mysqltuner.pl index 3b0d8bc6d..1b4e77359 100755 --- a/mysqltuner.pl +++ b/mysqltuner.pl @@ -4254,7 +4254,10 @@ sub get_other_process_memory { } sub get_load_average { - if ( -f "/proc/loadavg" ) { + my $prefix = get_transport_prefix(); + return () if $prefix eq '' && is_remote(); + + if ( $prefix eq '' && -f "/proc/loadavg" ) { my $content = file2string("/proc/loadavg") // ''; if ( $content =~ /^([\d.]+)\s+([\d.]+)\s+([\d.]+)/ ) { return ( $1, $2, $3 ); @@ -11762,6 +11765,8 @@ sub historical_comparison { my $file = $opt{'compare-file'}; return if !$file; + calculate_health_score() unless defined $result{'HealthScore'}; + subheaderprint "Historical Trend Analysis"; if ( !-e $file ) { badprint "Comparison file not found: $file"; @@ -12827,6 +12832,31 @@ sub format_recommendation_item { return $item // ''; } +sub _yaml_scalar { + my ( $v, $indent ) = @_; + return "~" unless defined $v; + + if ( $v =~ /\n/ ) { + my $pad = ' ' x ( $indent + 1 ); + $v =~ s/\r\n?/\n/g; + my @lines = split /\n/, $v, -1; + if ( @lines > 1 && $lines[-1] eq '' ) { + pop @lines; + } + my $joined = join( "\n", map { $pad . $_ } @lines ); + return "|\n" . $joined . "\n"; + } + + if ( $v eq '' + || $v =~ /[:\#\'\"\[\]\{\},&*?!|>%-]/ + || $v =~ /^(?:yes|no|true|false|null|on|off|~)$/i ) + { + $v =~ s/'/''/g; + return "'$v'"; + } + return $v; +} + sub _to_yaml { my ( $data, $indent ) = @_; $indent //= 0; @@ -12845,12 +12875,13 @@ sub _to_yaml { $output .= _to_yaml( $val, $indent + 1 ); } else { - my $v = $val // ''; - if ( $v =~ /[:\#\n\'\"]/ or $v eq '' ) { - $v =~ s/'/''/g; - $v = "'$v'"; + my $yaml_val = _yaml_scalar( $val, $indent ); + if ( $yaml_val =~ /^\|/ ) { + $output .= " " . $yaml_val; + } + else { + $output .= " " . $yaml_val . "\n"; } - $output .= " $v\n"; } } } @@ -12864,27 +12895,63 @@ sub _to_yaml { $output .= " " . $inner; } else { - my $v = $item // ''; - if ( $v =~ /[:\#\n\'\"]/ or $v eq '' ) { - $v =~ s/'/''/g; - $v = "'$v'"; + my $yaml_val = _yaml_scalar( $item, $indent ); + if ( $yaml_val =~ /^\|/ ) { + $output .= " " . $yaml_val; + } + else { + $output .= " " . $yaml_val . "\n"; } - $output .= " $v\n"; } } } else { - my $v = $data; - if ( $v =~ /[:\#\n\'\"]/ or $v eq '' ) { - $v =~ s/'/''/g; - $v = "'$v'"; + my $yaml_val = _yaml_scalar( $data, $indent ); + if ( $yaml_val =~ /^\|/ ) { + $output .= $yaml_val; + } + else { + $output .= $yaml_val . "\n"; } - $output .= "$v\n"; } return $output; } +sub _sanitized_result_for_export { + my $orig = shift; + my %copy = %$orig; + + if ( ref $copy{'MySQLTuner'} eq 'HASH' ) { + my %mt_copy = %{ $copy{'MySQLTuner'} }; + if ( ref $mt_copy{'options'} eq 'HASH' ) { + my %opts = %{ $mt_copy{'options'} }; + for my $secret (qw(pass password ssh-password passenv userenv)) { + $opts{$secret} = '[REDACTED]' if defined $opts{$secret}; + } + $mt_copy{'options'} = \%opts; + } + $copy{'MySQLTuner'} = \%mt_copy; + } + + if ( ref $copy{'MySQL Client'} eq 'HASH' ) { + my %mc_copy = %{ $copy{'MySQL Client'} }; + if ( defined $mc_copy{'Authentication Info'} ) { + my $auth = $mc_copy{'Authentication Info'}; + $auth =~ s/-p'[^']*'/-p'[REDACTED]'/g; + $auth =~ s/-p(?!'\[REDACTED\]')\S+/-p[REDACTED]/g; + $mc_copy{'Authentication Info'} = $auth; + } + $copy{'MySQL Client'} = \%mc_copy; + } + + return \%copy; +} + sub dump_result { + if ( $opt{'json'} && $opt{'yaml'} ) { + print STDERR "ERROR: --json and --yaml are mutually exclusive\n"; + return 1; + } #debugprint Dumper( \%result ) if ( $opt{'debug'} ); debugprint "HTML REPORT: " . ( $opt{'reportfile'} // 'undef' ); @@ -13459,6 +13526,11 @@ sub dump_result { goodprint "HTML Report successfully generated: " . $opt{'reportfile'}; } + my $sanitized_res; + if ( $opt{'json'} || $opt{'yaml'} ) { + $sanitized_res = _sanitized_result_for_export( \%result ); + } + if ( $opt{'json'} ) { eval { require JSON }; if ($@) { @@ -13468,7 +13540,7 @@ sub dump_result { my $json = JSON->new->allow_nonref; print $json->utf8(1)->pretty( ( $opt{'prettyjson'} ? 1 : 0 ) ) - ->encode( \%result ); + ->encode($sanitized_res); if ( $opt{'outputfile'} ) { unlink $opt{'outputfile'} if ( -e $opt{'outputfile'} ); @@ -13476,13 +13548,13 @@ sub dump_result { or die "Unable to open $opt{'outputfile'} in write mode. please check permissions for this file or directory"; print $fh $json->utf8(1)->pretty( ( $opt{'prettyjson'} ? 1 : 0 ) ) - ->encode( \%result ); + ->encode($sanitized_res); close $fh; } } if ( $opt{'yaml'} ) { - my $yaml_str = _to_yaml( \%result ); + my $yaml_str = _to_yaml($sanitized_res); print $yaml_str; if ( $opt{'outputfile'} ) { @@ -13544,6 +13616,9 @@ sub dump_csv_files { open( $fh, '>', $outputfile_path ) or die("Failed to open $outputfile_path for writing: $!"); $opt{nocolor} = 1; # Disable colors in file output + if (@raw_output_lines) { + print $fh join( "\n", @raw_output_lines ), "\n"; + } } # If outputfile is already set, create a second file handle for raw output diff --git a/releases/v2.9.0.md b/releases/v2.9.0.md index a859f1732..ae3301c51 100644 --- a/releases/v2.9.0.md +++ b/releases/v2.9.0.md @@ -14,13 +14,20 @@ - feat(report): move dump_csv_files execution step to immediately before make_recommendations. - feat(report): print an environment audit snapshot summary (server, user, RAM, swap, versions, uptime) right after get_all_vars. - feat(report): support historical comparison of database diagnostics and performance metrics over time. +- fix(cli): add mutually exclusive guard for json and yaml options. - fix(main): calculate query cache efficiency using Com_select on MariaDB, where Com_select includes query cache hits (MDEV-4981). -- fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. +- fix(main): calculate health score early in historical comparison to ensure scores exist for trend analysis. +- fix(main): format YAML null as tilde (~) and multiline values using literal block style. - fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables. - fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks. -- test(lab): add unit tests for query cache efficiency logic on MySQL and MariaDB in tests/issue_927.t. +- fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. +- fix(main): sanitize and redact sensitive credentials from json/yaml output exports. +- fix(main): skip local /proc/loadavg read during remote database audits. +- fix(report): flush console output trace when opening raw log files late in dumpdir mode. +- test(lab): add unit tests for query cache efficiency logic on MySQL and MariaDB in tests/test_issue_927.t (renamed from tests/issue_927.t). +- test(lab): utilize isolated tempfile for mock json to avoid race conditions in tests/unit_phase13_kpis.t. - test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t. -- test(versions): add unit tests for version caching and comparisons in tests/unit_versions.t. +- test(versions): add unit tests for version caching and comparisons, resolve redundant warnings, and use tempfile in tests/unit_versions.t. ``` ## 📈 Diagnostic Growth Indicators diff --git a/tests/issue_927.t b/tests/test_issue_927.t similarity index 100% rename from tests/issue_927.t rename to tests/test_issue_927.t diff --git a/tests/unit_phase13_kpis.t b/tests/unit_phase13_kpis.t index 0d4df5412..d55ea357d 100644 --- a/tests/unit_phase13_kpis.t +++ b/tests/unit_phase13_kpis.t @@ -5,6 +5,7 @@ no warnings 'once'; use Test::More; use File::Basename; use File::Spec; +use File::Temp qw(tempfile); # Get the script path my $script_path = File::Spec->rel2abs(File::Spec->catfile(dirname(__FILE__), '..', 'mysqltuner.pl')); @@ -19,9 +20,8 @@ my $script_path = File::Spec->rel2abs(File::Spec->catfile(dirname(__FILE__), '.. package main; # Write a mock JSON file for historical delta testing -my $mock_old_file = 'mock_old.json'; -open(my $fh, '>', $mock_old_file) or die "Could not open $mock_old_file: $!"; -print $fh q({ +my ( $mock_old_fh, $mock_old_file ) = tempfile( 'mock_old_XXXX', SUFFIX => '.json', UNLINK => 1 ); +print $mock_old_fh q({ "General": { "Date": "2026-06-01" }, "Stats": { "QPS": 10.0, @@ -36,14 +36,7 @@ print $fh q({ "Modeling": 90 } }); -close($fh); - -# Ensure cleanup on exit -END { - if (-e 'mock_old.json') { - unlink('mock_old.json'); - } -} +close($mock_old_fh); # 1. Test get_load_average with mock file2string or fallback to execute_system_command { diff --git a/tests/unit_versions.t b/tests/unit_versions.t index 5d1953021..a6e31e31d 100644 --- a/tests/unit_versions.t +++ b/tests/unit_versions.t @@ -1,8 +1,8 @@ use strict; use warnings; no warnings 'once'; -no warnings 'once'; use Test::More; +use File::Temp qw(tempfile); use File::Basename; use File::Spec; use Cwd 'abs_path'; @@ -100,16 +100,13 @@ subtest 'historical_comparison health score' => sub { my $compare_json = '{"General":{"Date":"2026-06-15"},"HealthScore":70,"Stats":{"QPS":1.5,"Total Data Size":1000}}'; - my $temp_file = File::Spec->catfile( $script_dir, 'temp_compare.json' ); - open my $tfh, '>', $temp_file or die $!; + my ( $tfh, $temp_file ) = tempfile( 'temp_compare_XXXX', SUFFIX => '.json', UNLINK => 1 ); print $tfh $compare_json; close $tfh; local $main::opt{'compare-file'} = $temp_file; main::historical_comparison(); - unlink $temp_file; - ok( defined $captured_trend, "Health score trend comparison was triggered" ); like( @@ -119,4 +116,47 @@ subtest 'historical_comparison health score' => sub { ); }; +subtest '_sanitized_result_for_export' => sub { + my $mock_result = { + 'MySQLTuner' => { + 'options' => { + 'host' => 'localhost', + 'pass' => 'mysecretpassword', + 'password' => 'anothersecret', + 'ssh-password' => 'sshsec', + 'passenv' => 'ENVVAR', + 'userenv' => 'USERVAR', + } + }, + 'MySQL Client' => { + 'Authentication Info' => "mysql -u root -p'mysecretpassword' -h localhost" + } + }; + + my $sanitized = main::_sanitized_result_for_export($mock_result); + + # Original remains unmodified + is($mock_result->{'MySQLTuner'}{'options'}{'pass'}, 'mysecretpassword', 'Original remains unmodified'); + + # Sanitized has redacted fields + is($sanitized->{'MySQLTuner'}{'options'}{'host'}, 'localhost', 'Host is kept'); + is($sanitized->{'MySQLTuner'}{'options'}{'pass'}, '[REDACTED]', 'pass is redacted'); + is($sanitized->{'MySQLTuner'}{'options'}{'password'}, '[REDACTED]', 'password is redacted'); + is($sanitized->{'MySQLTuner'}{'options'}{'ssh-password'}, '[REDACTED]', 'ssh-password is redacted'); + is($sanitized->{'MySQLTuner'}{'options'}{'passenv'}, '[REDACTED]', 'passenv is redacted'); + is($sanitized->{'MySQLTuner'}{'options'}{'userenv'}, '[REDACTED]', 'userenv is redacted'); + + # Authentication Info is sanitized + like($sanitized->{'MySQL Client'}{'Authentication Info'}, qr/-p'\[REDACTED\]'/, 'Authentication Info password in quotes is redacted'); + + # Test without quotes + my $mock_result2 = { + 'MySQL Client' => { + 'Authentication Info' => "mysql -u root -psecret -h localhost" + } + }; + my $sanitized2 = main::_sanitized_result_for_export($mock_result2); + like($sanitized2->{'MySQL Client'}{'Authentication Info'}, qr/-p\[REDACTED\]/, 'Authentication Info password without quotes is redacted'); +}; + done_testing(); From abe582adb0852e3f091215cd03c0cba301128e9a Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Tue, 23 Jun 2026 20:06:14 +0200 Subject: [PATCH 09/16] docs: regenerate release notes --- releases/v2.9.0.md | 58 +++++++--------------------------------------- 1 file changed, 9 insertions(+), 49 deletions(-) diff --git a/releases/v2.9.0.md b/releases/v2.9.0.md index ae3301c51..e89aaf461 100644 --- a/releases/v2.9.0.md +++ b/releases/v2.9.0.md @@ -41,55 +41,14 @@ ## 🛠️ Internal Commit History -- Merge pull request #919 from major/renovate/peter-evans-create-pull-request-8.x (289d87f) -- chore(deps): update peter-evans/create-pull-request action to v8 (9ba4831) -- Merge pull request #924 from major/renovate/pin-dependencies (1f0200e) -- chore(deps): pin dependencies (41951bd) -- chore: automated project metadata update (a325db3) -- Merge pull request #920 from jmrenouard/2.8.45 (b049d0f) -- feat(system): display host/RAM recap in remote/cloud mode & server uptime in all configurations (dec2084) -- Merge remote-tracking branch 'origin/master' into 2.8.45 (0c36d11) -- chore(deps): lock file maintenance (#925) (f5a58bb) -- chore: automated project metadata update (941d960) -- Merge pull request #48 from derZ-dev/mariadb_12_3_lts (d437355) -- Added support for MariaDB 12.3 (latest LTS version) (3e901a4) -- docs: regenerate release notes (2c9a4e4) -- perf: optimize column exploration in mysql_tables (0e34fe9) -- chore: update documentation sync timestamp and execution logs (232918d) -- docs(metadata): update automatically generated README timestamp (24224ea) -- docs: regenerate release notes (34b3229) -- feat(cli): export both complete and filtered CSV files for sys views (ebd5409) -- test(lab): create tests/issue_923.t and restore tests/issue_864.t (be4bd30) -- chore: update execution logs and sync documentation timestamp (cab32f1) -- chore: automated project metadata update (5f0ae0d) -- Merge pull request #922 from stonio/patch-1 (07e8c85) -- fix(test): only verify release tag consistency if ref is a tag in check_release_files.sh (a0a3341) -- docs(metadata): update automatically generated README timestamp (abe1eef) -- docs: regenerate release notes (748bb03) -- feat(cli): bypass temptable_max_ram on MEMORY/MariaDB and verify temptable_max_mmap (94a19ee) -- Fix regression in Dockerfile entrypoint (5aec18d) -- docs: generate USAGE.md (8d0ddeb) -- chore: release v2.8.45 (0347fad) -- chore(deps): update all non-major dependencies to v21.0.2 (#918) (188a0f1) -- Merge pull request #917 from jmrenouard/master (37ddaeb) -- chore: automated project metadata update (40a279a) -- Merge pull request #916 from jmrenouard/v2.8.44 (0f24670) -- chore: automated project metadata update (c4c118a) -- Merge pull request #47 from jmrenouard/v2.8.44 (2c8ee3c) -- fix(security): patch tmp path traversal and brace-expansion DoS vulnerabilities (aa90853) -- feat(test): boost subroutine coverage to 92% and fix 3 Perl bugs (34f8e8a) -- docs: add Docker install instructions and releases location in all READMEs (e1b77ed) -- docs: regenerate release notes (849e4d2) -- feat(test): clean unit tests output and fix mock compilation warnings (35ecd52) -- chore(ci): ignore execution.log in gitignore (a9ca211) -- docs: regenerate release notes (acd852c) -- fix(cli): resolve false positive AUTO_INCREMENT near max capacity warning (#913) (39d96eb) -- chore(ci): update execution log with generate_usage outputs (1c60bff) -- feat(ci): implement version dry-run validation, update LTS support tests, and update execution log (a78c747) -- feat(ci): implement EOL sync, version dry-run validation, and git hooks (b59a2f7) -- docs: generate USAGE.md (225e3e4) -- feat(versions): update validate_mysql_version with 9.6 support and remove 9.5 (d2ca4d6) -- feat(report): re-implement HTML report and cleanup templates (d3b6d3c) +- fix(main): address PR #931 code review feedback and enhance test validations (64c28ce) +- feat(metadata): fix test badge and update version references in READMEs (a1be73c) +- docs(metadata): remove timestamp from doc-sync generated files (4575b0a) +- fix(main): calculate query cache efficiency using Com_select on MariaDB (MDEV-4981) (780c861) +- feat(report): add verbose timings, step percentages, and snapshot summary (c23198a) +- feat(report): implement Phase 13 sectional global indicators and KPIs (5f9cebb) +- chore: remove execution.log from git repository and sync docs (2a9b66e) +- feat(report): finalize HTML report, YAML output, and historical comparison (66ebab8) ## ⚙️ Technical Evolutions @@ -113,6 +72,7 @@ - `--statements_with_runtimes_in_95th_percentile` - `--statements_with_sorting` - `--statements_with_temp_tables` +- `--yaml` ### ➖ CLI Options Deprecated - `--data` From 136a9772402d6dd0dc2d1ccb428a685aa6109683 Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Tue, 23 Jun 2026 20:19:04 +0200 Subject: [PATCH 10/16] docs(roadmap): link strategic technical evolutions specification and enforce changelog staging --- .husky/commit-msg | 17 ++++- ROADMAP.md | 2 +- .../strategic_technical_evolutions.md | 65 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 documentation/specifications/strategic_technical_evolutions.md diff --git a/.husky/commit-msg b/.husky/commit-msg index 70bd3dd23..f92fa5d0c 100644 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1 +1,16 @@ -npx --no-install commitlint --edit "$1" +#!/bin/sh + +# Run commitlint first to validate the commit message format +npx --no-install commitlint --edit "$1" || exit 1 + +# Check if Changelog is staged when commit type is feat or fix +COMMIT_MSG_FILE="$1" +FIRST_LINE=$(head -n 1 "$COMMIT_MSG_FILE") + +if echo "$FIRST_LINE" | grep -qE "^(feat|fix)(\([^)]+\))?!?:\s" ; then + if ! git diff --cached --name-only | grep -q "^Changelog$"; then + echo "ERROR: Commit type 'feat' or 'fix' detected, but 'Changelog' is not staged." + echo "Please update and stage 'Changelog' before committing." + exit 1 + fi +fi diff --git a/ROADMAP.md b/ROADMAP.md index fe3d9bc5f..b82913430 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -231,7 +231,7 @@ To ensure consistency and high-density development, the following roles are defi * [x] **Compression & Efficiency**: * [x] **On-the-fly Compression**: Support for compressed `.gz` exports to minimize disk footprint in container/limited-storage environments. -## 🔮 Strategic Technical Evolutions +## 🔮 [Strategic Technical Evolutions](file:///documentation/specifications/strategic_technical_evolutions.md) * [ ] Set up a pipeline to automatically audit and verify reference link availability inside the repository documentation to prevent dead links. * [ ] Integrate standard documentation reference anchors dynamically within MySQLTuner CLI help screens and specific advisor output blocks. diff --git a/documentation/specifications/strategic_technical_evolutions.md b/documentation/specifications/strategic_technical_evolutions.md new file mode 100644 index 000000000..43f30b274 --- /dev/null +++ b/documentation/specifications/strategic_technical_evolutions.md @@ -0,0 +1,65 @@ +# Specification: Strategic Technical Evolutions + +- **Feature Name**: Strategic Technical Evolutions +- **Status**: Draft +- **Created Date**: 2026-06-23 + +## 🧠 Rationale + +As MySQLTuner-perl matures, maintaining release integrity, documentation synchronization, and developer workflow stability becomes as critical as the advisor logic itself. The "Strategic Technical Evolutions" address the governance, automation, and localization of the project's ecosystem. By implementing strict validation gates, automated release scripts, and documentation sanity checks, we ensure that the single-file tool is backed by robust, industrial-grade quality assurance processes. + +--- + +## 🛠️ User Scenarios + +### Scenario 1: CI/CD & Documentation Integrity +A contributor submits a pull request introducing a new advisor warning. They update the `README.md` and `ROADMAP.md` but copy-paste a broken reference link. +The CI/CD pipeline triggers the **Reference Link Availability Checker** and the **Roadmap Schema Validator**, identifies the exact file and line number of the broken link, and fails the build. The developer corrects the link, and the build passes. + +### Scenario 2: Automated & Fail-Safe Release Lifecycle +The Release Manager decides to cut version `v2.9.1`. Instead of manually editing version strings in six separate locations and running python generator scripts, they execute the **Interactive Release Orchestrator**: +1. The tool prompts: `Select version bump type: [major, minor, micro]`. +2. The user chooses `micro`. +3. The orchestrator automatically bumps the version from `v2.9.0` to `v2.9.1`, updates the 6 reference locations, parses the Git commit history to generate the release note in `releases/v2.9.1.md`, and populates the `Executive Summary` in the `Changelog`. +4. Before tagging, the **Release Artifact Validator** scans the new release file to ensure the structure matches standard markdown syntax. + +### Scenario 3: Pre-Commit Quality Guard +A developer edits `mysqltuner.pl` to add support for a new MySQL 9.x variable (a `feat` type commit). They attempt to run `git commit`. +The **Automated Changelog Formatting Verification** hook intercepts the commit, notices that a `feat` modification in the script was staged, but `Changelog` was not modified. The commit is rejected with a message reminding the developer to document their changes in `Changelog`. + +--- + +## 📋 User Stories + +| Title | Priority | Description | Rationale | Test Case | +| :--- | :--- | :--- | :--- | :--- | +| **1. Reference Link Auditor** | P2 | As a developer, I want a pipeline command to scan documentation files | To automatically prevent dead or broken URLs/paths in help files. | **GIVEN** a markdown file contains a broken link, **WHEN** the auditor runs, **THEN** it outputs a list of broken URLs/files with line numbers and exits with code 1. | +| **2. Dynamic Help Anchors** | P2 | As a DBA, I want unique reference anchors displayed alongside advisor recommendations | To quickly navigate to detailed documentation in the official database KB. | **GIVEN** MySQLTuner suggests changing a parameter, **WHEN** it runs, **THEN** it outputs an anchor like `[REF: INNODB-BP]` mapping to official KBs. | +| **3. Localized References** | P3 | As a non-English speaker, I want references in my own language | To understand advice without translation overhead. | **GIVEN** localized output is selected (e.g. Italian), **WHEN** reference URLs are printed, **THEN** they point to localized KB paths where available. | +| **4. Pre-commit Changelog Hook** | P1 | As a maintainer, I want the pre-commit hook to verify `Changelog` edits on `feat`/`fix` changes | To ensure every functional change is properly documented before commit. | **GIVEN** `mysqltuner.pl` is changed with a `feat`/`fix` intent, **WHEN** committing, **THEN** block the commit if `Changelog` has no staged changes. | +| **5. Containerized Validation** | P1 | As a developer, I want local pre-flight checks to run inside a minimal Docker container | To avoid "works on my machine" issues due to environmental differences. | **GIVEN** local changes are made, **WHEN** running `make test-containerized`, **THEN** execute the unit test suite inside an isolated minimal container. | +| **6. Interactive Orchestrator** | P1 | As a Release Manager, I want a single script to bump versions and run release note generators | To prevent manual synchronization errors across the 6 version reference locations. | **GIVEN** a release is requested, **WHEN** selecting micro/minor/major, **THEN** update `CURRENT_VERSION.txt`, `mysqltuner.pl` (3 references), `Changelog`, and create the release note file. | +| **7. Release Summary Auto-Sync** | P2 | As a Release Manager, I want release summaries automatically extracted from commit logs | To save time and ensure no changes are omitted from release notes. | **GIVEN** commits exist since the last release tag, **WHEN** generating release notes, **THEN** parse and populate the Executive Summary automatically. | +| **8. Release Artifact Validator** | P2 | As a maintainer, I want validation of new release notes syntax and metadata | To prevent malformed or broken release documentation in `releases/`. | **GIVEN** a release file is generated, **WHEN** verified, **THEN** assert it contains mandatory headers, matching version, and valid issue links. | +| **9. Roadmap Syntax Linter** | P3 | As a maintainer, I want structured linting for `ROADMAP.md` | To keep the roadmap file syntactically clean and resolve all spec file paths. | **GIVEN** `ROADMAP.md` is modified, **WHEN** linted, **THEN** check syntax constraints, category mappings, and existence of all specification files. | +| **10. Roadmap Progress Auto-Sync** | P3 | As a developer, I want roadmap checklist items to be marked complete automatically on commit | To reduce manual housekeeping of the roadmap project status. | **GIVEN** a commit with scope `feat(auth):` is merged, **WHEN** roadmap sync is triggered, **THEN** check and mark related checklist items as completed `[x]`. | + +--- + +## ✅ Verification Plan + +### Automated Tests +- **Link Auditor Verification**: + - Run the auditing script against test fixtures (e.g. `tests/fixtures/good_links.md` and `tests/fixtures/bad_links.md`) and verify the exit codes. +- **Git pre-commit Hook Verification**: + - Simulate staging a `feat: add feature` commit without modifying `Changelog` and check if git blocks the commit. +- **Version Orchestration Verification**: + - Run the orchestrator in dry-run mode (`--dry-run`) to verify that the version variables are correctly computed and replaced in a mock directory structure. +- **Docker Validation Runner**: + - Run `make test-containerized` and confirm the suite successfully executes inside a temporary Docker container and tears down cleanly. +- **Linter & Schema Verification**: + - Execute markdown schema validation scripts against `ROADMAP.md` and `releases/*.md` to confirm formatting compliance. + +### Manual Verification +- Execute `--help` and verify that documentation references are listed and dynamically generated. +- Run the localized script (e.g., with environment configuration) to verify translation mapping of reference domains. From 0a874829165947caeecaf67a1417dad6371456a0 Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Wed, 24 Jun 2026 11:10:19 +0200 Subject: [PATCH 11/16] fix(main): add undefined/NULL guards to hr_num to resolve uninitialized warnings --- Changelog | 3 +++ build/check_compliance.pl | 2 +- mysqltuner.pl | 1 + tests/issue_923.t | 5 +++-- tests/test_issue_927.t | 7 ++++--- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Changelog b/Changelog index ddfb2df24..7f03d646c 100644 --- a/Changelog +++ b/Changelog @@ -3,6 +3,7 @@ 2.9.0 2026-06-15 - chore(main): whitelist deps and system commit scopes in check_compliance.pl to support Dependabot and host metrics commits. +- chore(main): add roadmap to the whitelist of allowed scopes in compliance checks. - feat(cli): create an agent-ready output format (JSON/YAML) so that MySQLTuner can be easily integrated by AI agents. - feat(report): add verbose execution timing measurements for each section, showing both elapsed time and its percentage relative to the total script execution time. - feat(report): finalize a complete HTML report file beginning in v2.8.45. @@ -16,9 +17,11 @@ - fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables. - fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks. - fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. +- fix(main): add undefined and 'NULL' guards to hr_num to eliminate uninitialized value warnings. - fix(main): sanitize and redact sensitive credentials from json/yaml output exports. - fix(main): skip local /proc/loadavg read during remote database audits. - fix(report): flush console output trace when opening raw log files late in dumpdir mode. +- fix(test): add undef fallbacks to human_size, hr_bytes and hr_bytes_rnd mocks in tests. - test(lab): add unit tests for query cache efficiency logic on MySQL and MariaDB in tests/test_issue_927.t (renamed from tests/issue_927.t). - test(lab): utilize isolated tempfile for mock json to avoid race conditions in tests/unit_phase13_kpis.t. - test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t. diff --git a/build/check_compliance.pl b/build/check_compliance.pl index c17daec0f..672e1941b 100755 --- a/build/check_compliance.pl +++ b/build/check_compliance.pl @@ -140,7 +140,7 @@ 'options', 'lab', 'container', 'refactor', 'style', 'releases', 'dependencies', 'cli', 'auth', 'main', 'metadata', 'deps', - 'system' + 'system', 'roadmap' ); # Lint Changelog structure and scopes for the current version block diff --git a/mysqltuner.pl b/mysqltuner.pl index 1b4e77359..ab6de6fd2 100755 --- a/mysqltuner.pl +++ b/mysqltuner.pl @@ -1900,6 +1900,7 @@ sub hr_bytes_rnd { # Calculates the parameter passed to the nearest power of 1000, then rounds it to the nearest integer sub hr_num { my $num = shift; + return 0 if !$num || $num eq 'NULL'; if ( $num >= ( 1000**3 ) ) { # Billions return int( ( $num / ( 1000**3 ) ) ) . "B"; } diff --git a/tests/issue_923.t b/tests/issue_923.t index c2d0e8938..3b8576796 100644 --- a/tests/issue_923.t +++ b/tests/issue_923.t @@ -26,8 +26,8 @@ no warnings 'redefine'; no warnings 'uninitialized'; *main::debugprint = sub { }; *main::is_int = sub { return $_[0] && $_[0] =~ /^\d+$/ }; -*main::human_size = sub { return $_[0] }; -*main::hr_bytes = sub { return $_[0] }; +*main::human_size = sub { return $_[0] // '0B' }; +*main::hr_bytes = sub { return $_[0] // '0B' }; *main::percentage = sub { return 0 }; *main::badprint = sub { }; *main::infoprint = sub { }; @@ -213,6 +213,7 @@ subtest 'temptable_max_mmap disk space recommendations check' => sub { 'pct_writes' => 50, 'pct_temp_disk' => 0, 'thread_cache_hit_rate' => 90, + 'table_cache_hit_rate' => 99, 'total_sorts' => 0, 'joins_without_indexes_per_day' => 0, ); diff --git a/tests/test_issue_927.t b/tests/test_issue_927.t index 27d822440..342cc8c21 100644 --- a/tests/test_issue_927.t +++ b/tests/test_issue_927.t @@ -29,11 +29,12 @@ no warnings 'redefine'; no warnings 'uninitialized'; *main::debugprint = sub { }; *main::is_int = sub { return $_[0] && $_[0] =~ /^\d+$/ }; -*main::human_size = sub { return $_[0] }; -*main::hr_bytes = sub { return $_[0] }; -*main::hr_bytes_rnd = sub { return $_[0] }; +*main::human_size = sub { return $_[0] // '0B' }; +*main::hr_bytes = sub { return $_[0] // '0B' }; +*main::hr_bytes_rnd = sub { return $_[0] // '0B' }; *main::hr_num = sub { my $val = shift; + return 0 unless defined $val; # simple formatting for tests 1 while $val =~ s/(\d+)(\d{3})/$1,$2/; From a7541471b189b66b3b245b2768a28cb915a8219c Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Wed, 24 Jun 2026 11:10:19 +0200 Subject: [PATCH 12/16] docs: regenerate release notes --- releases/v2.9.0.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/releases/v2.9.0.md b/releases/v2.9.0.md index e89aaf461..c07924ef5 100644 --- a/releases/v2.9.0.md +++ b/releases/v2.9.0.md @@ -8,6 +8,7 @@ 2.9.0 2026-06-15 - chore(main): whitelist deps and system commit scopes in check_compliance.pl to support Dependabot and host metrics commits. +- chore(main): add roadmap to the whitelist of allowed scopes in compliance checks. - feat(cli): create an agent-ready output format (JSON/YAML) so that MySQLTuner can be easily integrated by AI agents. - feat(report): add verbose execution timing measurements for each section, showing both elapsed time and its percentage relative to the total script execution time. - feat(report): finalize a complete HTML report file beginning in v2.8.45. @@ -21,9 +22,11 @@ - fix(main): guard InnoDB log file size and log size percentage checks against uninitialized variables. - fix(main): guard version and version comment checks in MariaDB parallel replication and query cache blocks. - fix(main): implement cached version comparison parser to eliminate uninitialized value warnings and improve performance. +- fix(main): add undefined and 'NULL' guards to hr_num to eliminate uninitialized value warnings. - fix(main): sanitize and redact sensitive credentials from json/yaml output exports. - fix(main): skip local /proc/loadavg read during remote database audits. - fix(report): flush console output trace when opening raw log files late in dumpdir mode. +- fix(test): add undef fallbacks to human_size, hr_bytes and hr_bytes_rnd mocks in tests. - test(lab): add unit tests for query cache efficiency logic on MySQL and MariaDB in tests/test_issue_927.t (renamed from tests/issue_927.t). - test(lab): utilize isolated tempfile for mock json to avoid race conditions in tests/unit_phase13_kpis.t. - test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t. @@ -41,6 +44,9 @@ ## 🛠️ Internal Commit History +- fix(main): add undefined/NULL guards to hr_num to resolve uninitialized warnings (0a87482) +- docs(roadmap): link strategic technical evolutions specification and enforce changelog staging (136a977) +- docs: regenerate release notes (abe582a) - fix(main): address PR #931 code review feedback and enhance test validations (64c28ce) - feat(metadata): fix test badge and update version references in READMEs (a1be73c) - docs(metadata): remove timestamp from doc-sync generated files (4575b0a) From 4cd208e6c2aaedc9c7d807bb684393ef230b5022 Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Wed, 24 Jun 2026 15:10:17 +0200 Subject: [PATCH 13/16] docs: update repository links to major/MySQLTuner-perl and add GitHub stars badge --- README.fr.md | 49 ++++++++++++++++++++++++------------------------ README.it.md | 49 ++++++++++++++++++++++++------------------------ README.md | 53 ++++++++++++++++++++++++++-------------------------- README.ru.md | 49 ++++++++++++++++++++++++------------------------ 4 files changed, 102 insertions(+), 98 deletions(-) diff --git a/README.fr.md b/README.fr.md index 7448532a1..5a7488be8 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,11 +1,12 @@ ![MySQLTuner-perl](mtlogo2.png) [!["Offrez-nous un café"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) +[![GitHub stars](https://img.shields.io/github/stars/major/MySQLTuner-perl?style=social)](https://github.com/major/MySQLTuner-perl) [![État du projet](https://opensource.box.com/badges/active.svg)](https://opensource.box.com/badges) -[![État des tests](https://github.com/jmrenouard/MySQLTuner-perl/actions/workflows/pull_request.yml/badge.svg)](https://github.com/jmrenouard/MySQLTuner-perl/actions) -[![Temps moyen de résolution d'un problème](https://isitmaintained.com/badge/resolution/jmrenouard/MySQLTuner-perl.svg)](https://isitmaintained.com/project/jmrenouard/MySQLTuner-perl "Temps moyen de résolution d'un problème") -[![Pourcentage de problèmes ouverts](https://isitmaintained.com/badge/open/jmrenouard/MySQLTuner-perl.svg)](https://isitmaintained.com/project/jmrenouard/MySQLTuner-perl "Pourcentage de problèmes encore ouverts") +[![État des tests](https://github.com/major/MySQLTuner-perl/actions/workflows/pull_request.yml/badge.svg)](https://github.com/major/MySQLTuner-perl/actions) +[![Temps moyen de résolution d'un problème](https://isitmaintained.com/badge/resolution/major/MySQLTuner-perl.svg)](https://isitmaintained.com/project/major/MySQLTuner-perl "Temps moyen de résolution d'un problème") +[![Pourcentage de problèmes ouverts](https://isitmaintained.com/badge/open/major/MySQLTuner-perl.svg)](https://isitmaintained.com/project/major/MySQLTuner-perl "Pourcentage de problèmes encore ouverts") [![Licence GPL](https://badges.frapsoft.com/os/gpl/gpl.png?v=103)](https://opensource.org/licenses/GPL-3.0/) **MySQLTuner** est un script écrit en Perl qui vous permet d'examiner rapidement une installation MySQL et de faire des ajustements pour augmenter les performances et la stabilité. Les variables de configuration actuelles et les données d'état sont récupérées et présentées dans un bref format avec quelques suggestions de performances de base. @@ -15,16 +16,16 @@ **MySQLTuner** est activement maintenu et prend en charge de nombreuses configurations telles que [Galera Cluster](https://galeracluster.com/), [TokuDB](https://www.percona.com/software/mysql-database/percona-tokudb), [Schéma de performance](https://github.com/mysql/mysql-sys), les métriques du système d'exploitation Linux, [InnoDB](https://dev.mysql.com/doc/refman/5.7/en/innodb-storage-engine.html), [MyISAM](https://dev.mysql.com/doc/refman/5.7/en/myisam-storage-engine.html), [Aria](https://mariadb.com/docs/server/server-usage/storage-engines/aria/aria-storage-engine), ... Vous pouvez trouver plus de détails sur ces indicateurs ici : -[Description des indicateurs](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/INTERNALS.md). +[Description des indicateurs](https://github.com/major/MySQLTuner-perl/blob/master/INTERNALS.md). -![MysqlTuner](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mysqltuner.png) +![MysqlTuner](https://github.com/major/MySQLTuner-perl/blob/master/mysqltuner.png) Liens utiles == -* **Développement actif :** [https://github.com/jmrenouard/MySQLTuner-perl](https://github.com/jmrenouard/MySQLTuner-perl) -* **Versions/Tags :** [https://github.com/jmrenouard/MySQLTuner-perl/tags](https://github.com/jmrenouard/MySQLTuner-perl/tags) -* **Changelog :** [https://github.com/jmrenouard/MySQLTuner-perl/blob/master/Changelog](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/Changelog) +* **Développement actif :** [https://github.com/major/MySQLTuner-perl](https://github.com/major/MySQLTuner-perl) +* **Versions/Tags :** [https://github.com/major/MySQLTuner-perl/tags](https://github.com/major/MySQLTuner-perl/tags) +* **Changelog :** [https://github.com/major/MySQLTuner-perl/blob/master/Changelog](https://github.com/major/MySQLTuner-perl/blob/master/Changelog) * **Images Docker :** [https://hub.docker.com/repository/docker/jmrenouard/mysqltuner/tags](https://hub.docker.com/repository/docker/jmrenouard/mysqltuner/tags) MySQLTuner a besoin de vous @@ -32,9 +33,9 @@ MySQLTuner a besoin de vous **MySQLTuner** a besoin de contributeurs pour la documentation, le code et les commentaires : -* Veuillez nous rejoindre sur notre outil de suivi des problèmes sur [le suivi GitHub](https://github.com/jmrenouard/MySQLTuner-perl/issues). -* Le guide de contribution est disponible en suivant [le guide de contribution de MySQLTuner](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/CONTRIBUTING.md) -* Mettez une étoile au **projet MySQLTuner** sur [le projet Git Hub de MySQLTuner](https://github.com/jmrenouard/MySQLTuner-perl/) +* Veuillez nous rejoindre sur notre outil de suivi des problèmes sur [le suivi GitHub](https://github.com/major/MySQLTuner-perl/issues). +* Le guide de contribution est disponible en suivant [le guide de contribution de MySQLTuner](https://github.com/major/MySQLTuner-perl/blob/master/CONTRIBUTING.md) +* Mettez une étoile au **projet MySQLTuner** sur [le projet Git Hub de MySQLTuner](https://github.com/major/MySQLTuner-perl/) * Support payant pour LightPath ici : [jmrenouard@lightpath.fr](jmrenouard@lightpath.fr) * Support payant pour Releem disponible ici : [Application Releem](https://releem.com/) @@ -42,7 +43,7 @@ MySQLTuner a besoin de vous ## Stargazers au fil du temps -[![Stargazers au fil du temps](https://starchart.cc/jmrenouard/MySQLTuner-perl.svg)](https://starchart.cc/jmrenouard/MySQLTuner-perl) +[![Stargazers au fil du temps](https://starchart.cc/major/MySQLTuner-perl.svg)](https://starchart.cc/major/MySQLTuner-perl) Compatibilité ==== @@ -58,8 +59,8 @@ Les résultats des tests sont disponibles ici uniquement pour les versions LTS  Merci à [endoflife.date](https://endoflife.date/) -* Reportez-vous aux [versions prises en charge de MariaDB](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mariadb_support.md). -* Reportez-vous aux [versions prises en charge de MySQL](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/mysql_support.md). +* Reportez-vous aux [versions prises en charge de MariaDB](https://github.com/major/MySQLTuner-perl/blob/master/mariadb_support.md). +* Reportez-vous aux [versions prises en charge de MySQL](https://github.com/major/MySQLTuner-perl/blob/master/mysql_support.md). ***La prise en charge de Windows est partielle*** @@ -156,12 +157,12 @@ Recommandations de sécurité Salut l'utilisateur de directadmin ! Nous avons détecté que vous exécutez mysqltuner avec les informations d'identification de da_admin extraites de `/usr/local/directadmin/conf/my.cnf`, ce qui pourrait entraîner une découverte de mot de passe ! -Lisez le lien pour plus de détails [Problème n°289](https://github.com/jmrenouard/MySQLTuner-perl/issues/289). +Lisez le lien pour plus de détails [Problème n°289](https://github.com/major/MySQLTuner-perl/issues/289). Que vérifie exactement MySQLTuner ? -- -Toutes les vérifications effectuées par **MySQLTuner** sont documentées dans la documentation [MySQLTuner Internals](https://github.com/jmrenouard/MySQLTuner-perl/blob/master/INTERNALS.md). +Toutes les vérifications effectuées par **MySQLTuner** sont documentées dans la documentation [MySQLTuner Internals](https://github.com/major/MySQLTuner-perl/blob/master/INTERNALS.md). **MySQLTuner** analyse les domaines suivants : @@ -190,14 +191,14 @@ Choisissez l'une de ces méthodes : ```bash wget https://mysqltuner.pl/ -O mysqltuner.pl -wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt -wget https://raw.githubusercontent.com/jmrenouard/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv +wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/basic_passwords.txt -O basic_passwords.txt +wget https://raw.githubusercontent.com/major/MySQLTuner-perl/master/vulnerabilities.csv -O vulnerabilities.csv ``` 2) Vous pouvez télécharger l'intégralité du référentiel en utilisant `git clone` ou `git clone --depth 1 -b master` suivi de l'URL de clonage ci-dessus. ```bash -git clone --depth 1 -b master https://github.com/jmrenouard/MySQLTuner-perl.git +git clone --depth 1 -b master https://github.com/major/MySQLTuner-perl.git ``` 3) Sur Apple macOS, installez via [Homebrew](https://brew.sh/) : @@ -218,7 +219,7 @@ docker run --rm -it jmrenouard/mysqltuner --host --user --user --user --user Date: Wed, 24 Jun 2026 15:23:44 +0200 Subject: [PATCH 14/16] docs: add LightPath as sponsor, relocate coffee button, and use star-history chart --- README.fr.md | 21 +++++++++++++++++---- README.it.md | 21 +++++++++++++++++---- README.md | 21 +++++++++++++++++---- README.ru.md | 21 +++++++++++++++++---- 4 files changed, 68 insertions(+), 16 deletions(-) diff --git a/README.fr.md b/README.fr.md index 5a7488be8..6f52f15dc 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,7 +1,6 @@ ![MySQLTuner-perl](mtlogo2.png) -[!["Offrez-nous un café"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) -[![GitHub stars](https://img.shields.io/github/stars/major/MySQLTuner-perl?style=social)](https://github.com/major/MySQLTuner-perl) +[![GitHub stars](https://img.shields.io/github/stars/major/MySQLTuner-perl?style=for-the-badge&logo=github)](https://github.com/major/MySQLTuner-perl) [![État du projet](https://opensource.box.com/badges/active.svg)](https://opensource.box.com/badges) [![État des tests](https://github.com/major/MySQLTuner-perl/actions/workflows/pull_request.yml/badge.svg)](https://github.com/major/MySQLTuner-perl/actions) @@ -39,11 +38,25 @@ MySQLTuner a besoin de vous * Support payant pour LightPath ici : [jmrenouard@lightpath.fr](jmrenouard@lightpath.fr) * Support payant pour Releem disponible ici : [Application Releem](https://releem.com/) +### Sponsors + +Le développement actif est sponsorisé par : + +

    + + LightPath + +

    + +Merci à LightPath pour la mise à disposition des ressources (serveurs de développement, abonnement IA, environnements de recette & fonctionnalités). + ![Statistiques GitHub de jmrenouard](https://github-readme-stats.vercel.app/api?username=jmrenouard&show_icons=true&theme=radical) -## Stargazers au fil du temps +[!["Offrez-nous un café"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) + +## Historique des étoiles -[![Stargazers au fil du temps](https://starchart.cc/major/MySQLTuner-perl.svg)](https://starchart.cc/major/MySQLTuner-perl) +[![Star History Chart](https://api.star-history.com/svg?repos=major/MySQLTuner-perl&type=Date)](https://star-history.com/#major/MySQLTuner-perl&Date) Compatibilité ==== diff --git a/README.it.md b/README.it.md index 1b6ae7a8a..c5a763293 100644 --- a/README.it.md +++ b/README.it.md @@ -1,7 +1,6 @@ ![MySQLTuner-perl](mtlogo2.png) -[!["Offrici un caffè"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) -[![GitHub stars](https://img.shields.io/github/stars/major/MySQLTuner-perl?style=social)](https://github.com/major/MySQLTuner-perl) +[![GitHub stars](https://img.shields.io/github/stars/major/MySQLTuner-perl?style=for-the-badge&logo=github)](https://github.com/major/MySQLTuner-perl) [![Stato del progetto](https://opensource.box.com/badges/active.svg)](https://opensource.box.com/badges) [![Stato dei test](https://github.com/major/MySQLTuner-perl/actions/workflows/pull_request.yml/badge.svg)](https://github.com/major/MySQLTuner-perl/actions) @@ -39,11 +38,25 @@ MySQLTuner ha bisogno di te * Supporto a pagamento per LightPath qui: [jmrenouard@lightpath.fr](jmrenouard@lightpath.fr) * Supporto a pagamento per Releem disponibile qui: [App Releem](https://releem.com/) +### Sponsors + +Lo sviluppo attivo è sponsorizzato da: + +

    + + LightPath + +

    + +Grazie a LightPath per aver fornito risorse (server di sviluppo, abbonamento IA, staging e funzionalità). + ![Statistiche GitHub di jmrenouard](https://github-readme-stats.vercel.app/api?username=jmrenouard&show_icons=true&theme=radical) -## Stargazer nel tempo +[!["Offrici un caffè"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) + +## Cronologia delle stelle -[![Stargazer nel tempo](https://starchart.cc/major/MySQLTuner-perl.svg)](https://starchart.cc/major/MySQLTuner-perl) +[![Star History Chart](https://api.star-history.com/svg?repos=major/MySQLTuner-perl&type=Date)](https://star-history.com/#major/MySQLTuner-perl&Date) Compatibilità ==== diff --git a/README.md b/README.md index d9e2bcd88..84c335883 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ ![MySQLTuner-perl](mtlogo2.png) -[!["Buy Us A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) -[![GitHub stars](https://img.shields.io/github/stars/major/MySQLTuner-perl?style=social)](https://github.com/major/MySQLTuner-perl) +[![GitHub stars](https://img.shields.io/github/stars/major/MySQLTuner-perl?style=for-the-badge&logo=github)](https://github.com/major/MySQLTuner-perl) [![Project Status](https://opensource.box.com/badges/active.svg)](https://opensource.box.com/badges) [![MySQLTuner Version](https://img.shields.io/badge/version-2.9.0-blue.svg)](https://github.com/major/MySQLTuner-perl/releases/tag/v2.9.0) @@ -41,11 +40,25 @@ MySQLTuner needs you * Paid support for LightPath here: [jmrenouard@lightpath.fr](jmrenouard@lightpath.fr) * Paid support for Releem available here: [Releem App](https://releem.com/) +### Sponsors + +Active development is sponsored by: + +

    + + LightPath + +

    + +Thanks to LightPath for providing resources (dev servers, AI subscriptions, staging & features). + ![jmrenouard's GitHub stats](https://github-readme-stats.vercel.app/api?username=jmrenouard&show_icons=true&theme=radical) -## Stargazers over time +[!["Buy Us A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) + +## Star History -[![Stargazers over time](https://starchart.cc/major/MySQLTuner-perl.svg)](https://starchart.cc/major/MySQLTuner-perl) +[![Star History Chart](https://api.star-history.com/svg?repos=major/MySQLTuner-perl&type=Date)](https://star-history.com/#major/MySQLTuner-perl&Date) Compatibility ==== diff --git a/README.ru.md b/README.ru.md index 4f9073412..ac4002d7a 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,7 +1,6 @@ ![MySQLTuner-perl](mtlogo2.png) -[!["Купите нам кофе"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) -[![GitHub stars](https://img.shields.io/github/stars/major/MySQLTuner-perl?style=social)](https://github.com/major/MySQLTuner-perl) +[![GitHub stars](https://img.shields.io/github/stars/major/MySQLTuner-perl?style=for-the-badge&logo=github)](https://github.com/major/MySQLTuner-perl) [![Статус проекта](https://opensource.box.com/badges/active.svg)](https://opensource.box.com/badges) [![Статус тестов](https://github.com/major/MySQLTuner-perl/actions/workflows/pull_request.yml/badge.svg)](https://github.com/major/MySQLTuner-perl/actions) @@ -39,11 +38,25 @@ MySQLTuner нуждается в вас * Платная поддержка LightPath здесь: [jmrenouard@lightpath.fr](jmrenouard@lightpath.fr) * Платная поддержка Releem доступна здесь: [приложение Releem](https://releem.com/) +### Спонсоры + +Активная разработка спонсируется: + +

    + + LightPath + +

    + +Спасибо LightPath за предоставление ресурсов (серверы разработки, подписка на ИИ, стейджинг и новые функции). + ![Статистика GitHub jmrenouard](https://github-readme-stats.vercel.app/api?username=jmrenouard&show_icons=true&theme=radical) -## Звездочеты с течением времени +[!["Купите нам кофе"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/jmrenouard) + +## История звезд -[![Звездочеты с течением времени](https://starchart.cc/major/MySQLTuner-perl.svg)](https://starchart.cc/major/MySQLTuner-perl) +[![Star History Chart](https://api.star-history.com/svg?repos=major/MySQLTuner-perl&type=Date)](https://star-history.com/#major/MySQLTuner-perl&Date) Совместимость ==== From da6d4375e433f6714edf2f6a3563950b4797fec5 Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Wed, 24 Jun 2026 20:04:34 +0200 Subject: [PATCH 15/16] feat: recommend slow query log when disabled (#517) --- Changelog | 6 + POTENTIAL_ISSUES.md | 12 +- mysqltuner.pl | 5 +- tests/{repro_issue_20.t => test_issue_20.t} | 0 tests/{repro_issue_22.t => test_issue_22.t} | 0 tests/{issue_33.t => test_issue_33.t} | 0 tests/{issue_36.t => test_issue_36.t} | 0 tests/{issue_37.t => test_issue_37.t} | 0 tests/{issue_42.t => test_issue_42.t} | 0 tests/test_issue_480.t | 59 ++++++ tests/{repro_issue_490.t => test_issue_490.t} | 0 tests/test_issue_517.t | 168 ++++++++++++++++++ tests/{repro_issue_605.t => test_issue_605.t} | 0 tests/{issue_770.t => test_issue_770.t} | 0 tests/{issue_774.t => test_issue_774.t} | 0 tests/{issue_777.t => test_issue_777.t} | 0 tests/{issue_781.t => test_issue_781.t} | 0 tests/{issue_782.t => test_issue_782.t} | 0 tests/{issue_783.t => test_issue_783.t} | 0 tests/{repro_issue_863.t => test_issue_863.t} | 0 ...3_enhanced.t => test_issue_863_enhanced.t} | 0 tests/{issue_864.t => test_issue_864.t} | 0 tests/{issue_869.t => test_issue_869.t} | 0 .../{issue_881_887.t => test_issue_881_887.t} | 0 tests/{issue_888.t => test_issue_888.t} | 0 tests/{issue_896.t => test_issue_896.t} | 0 tests/{issue_904.t => test_issue_904.t} | 0 tests/{issue_913.t => test_issue_913.t} | 0 tests/{issue_923.t => test_issue_923.t} | 0 tests/unit_cli_helpers.t | 153 ++++++++++++++++ tests/unit_client_privileges.t | 119 +++++++++++++ tests/unit_cloud_commands.t | 133 ++++++++++++++ tests/unit_fs_info.t | 103 +++++++++++ tests/unit_os_vm.t | 114 ++++++++++++ 34 files changed, 863 insertions(+), 9 deletions(-) rename tests/{repro_issue_20.t => test_issue_20.t} (100%) rename tests/{repro_issue_22.t => test_issue_22.t} (100%) rename tests/{issue_33.t => test_issue_33.t} (100%) rename tests/{issue_36.t => test_issue_36.t} (100%) rename tests/{issue_37.t => test_issue_37.t} (100%) rename tests/{issue_42.t => test_issue_42.t} (100%) create mode 100644 tests/test_issue_480.t rename tests/{repro_issue_490.t => test_issue_490.t} (100%) create mode 100644 tests/test_issue_517.t rename tests/{repro_issue_605.t => test_issue_605.t} (100%) rename tests/{issue_770.t => test_issue_770.t} (100%) rename tests/{issue_774.t => test_issue_774.t} (100%) rename tests/{issue_777.t => test_issue_777.t} (100%) rename tests/{issue_781.t => test_issue_781.t} (100%) rename tests/{issue_782.t => test_issue_782.t} (100%) rename tests/{issue_783.t => test_issue_783.t} (100%) rename tests/{repro_issue_863.t => test_issue_863.t} (100%) rename tests/{issue_863_enhanced.t => test_issue_863_enhanced.t} (100%) rename tests/{issue_864.t => test_issue_864.t} (100%) rename tests/{issue_869.t => test_issue_869.t} (100%) rename tests/{issue_881_887.t => test_issue_881_887.t} (100%) rename tests/{issue_888.t => test_issue_888.t} (100%) rename tests/{issue_896.t => test_issue_896.t} (100%) rename tests/{issue_904.t => test_issue_904.t} (100%) rename tests/{issue_913.t => test_issue_913.t} (100%) rename tests/{issue_923.t => test_issue_923.t} (100%) create mode 100644 tests/unit_cli_helpers.t create mode 100644 tests/unit_client_privileges.t create mode 100644 tests/unit_cloud_commands.t create mode 100644 tests/unit_fs_info.t create mode 100644 tests/unit_os_vm.t diff --git a/Changelog b/Changelog index 7f03d646c..f34c6e006 100644 --- a/Changelog +++ b/Changelog @@ -26,6 +26,12 @@ - test(lab): utilize isolated tempfile for mock json to avoid race conditions in tests/unit_phase13_kpis.t. - test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t. - test(versions): add unit tests for version caching and comparisons, resolve redundant warnings, and use tempfile in tests/unit_versions.t. +- feat(main): recommend enabling slow query log if disabled (#517) +- test(lab): add unit test test_issue_517.t for slow query log recommendations (#517) +- test(lab): add unit test test_issue_480.t for table_open_cache_instances recommendation (#480) +- test(lab): split unit_coverage_boost4.t into smaller topic-oriented unit tests: unit_cli_helpers.t, unit_client_privileges.t, unit_cloud_commands.t, unit_fs_info.t, unit_os_vm.t +- test(lab): normalize all repro_issue_*.t and issue_*.t test file names to test_issue_*.t + 2.8.45 2026-06-04 diff --git a/POTENTIAL_ISSUES.md b/POTENTIAL_ISSUES.md index 6c1ded88f..2ad5ef2eb 100644 --- a/POTENTIAL_ISSUES.md +++ b/POTENTIAL_ISSUES.md @@ -16,15 +16,12 @@ This file records anomalies discovered during laboratory testing (Perl warnings, | Metric | Value | |:---|:---| | Total Subroutines | 167 | -| Tested Subroutines | ~154 (~92%) | -| Untested Subroutines | ~13 (~8%) | +| Tested Subroutines | 167 (100%) | +| Untested Subroutines | 0 (0%) | #### Remaining Untested Subroutines (System/IO-Heavy) -- `check_privileges`, `cloud_setup`, `get_fs_info`, `get_fs_info_win` -- `get_http_cli`, `get_os_release`, `get_tuning_info` -- `infoprintcmd`, `infoprinthcmd`, `is_virtual_machine` -- `parse_cli_args`, `show_help` (x2) +- None (100% subroutine coverage reached) ### 🔴 Critical Issues @@ -58,7 +55,8 @@ This file records anomalies discovered during laboratory testing (Perl warnings, #### PI-006: 13 out of 167 subroutines have zero test coverage - **Impact**: Remaining untested functions are mostly system-level (filesystem, OS detection, cloud setup) or CLI helpers (`show_help`, `parse_cli_args`) - **Severity**: 🟢 LOW — core diagnostic functions now fully covered -- **Coverage rate**: ~92% of subroutines referenced in at least one test (improved from ~55% → 62% → 78% → 92%) +- **Coverage rate**: 100% of subroutines referenced in at least one test (improved from ~55% → 62% → 78% → 92% → 100%) +- **Status**: [x] **FIXED** — All remaining subroutines covered in `tests/unit_coverage_boost4.t`. #### PI-007: Extremely large subroutines - **Impact**: Several functions exceed 500+ lines, making maintenance difficult diff --git a/mysqltuner.pl b/mysqltuner.pl index ab6de6fd2..1868d171d 100755 --- a/mysqltuner.pl +++ b/mysqltuner.pl @@ -7126,8 +7126,9 @@ sub mysql_stats { if ( $myvar{'long_query_time'} > 10 ) { push( @adjvars, "long_query_time (<= 10)" ); } - if ( defined( $myvar{'log_slow_queries'} ) ) { - if ( $myvar{'log_slow_queries'} eq "OFF" ) { + my $slow_query_log_active = $myvar{'slow_query_log'} // $myvar{'log_slow_queries'}; + if ( defined( $slow_query_log_active ) ) { + if ( $slow_query_log_active eq "OFF" ) { push( @generalrec, "Enable the slow query log to troubleshoot bad queries" ); } diff --git a/tests/repro_issue_20.t b/tests/test_issue_20.t similarity index 100% rename from tests/repro_issue_20.t rename to tests/test_issue_20.t diff --git a/tests/repro_issue_22.t b/tests/test_issue_22.t similarity index 100% rename from tests/repro_issue_22.t rename to tests/test_issue_22.t diff --git a/tests/issue_33.t b/tests/test_issue_33.t similarity index 100% rename from tests/issue_33.t rename to tests/test_issue_33.t diff --git a/tests/issue_36.t b/tests/test_issue_36.t similarity index 100% rename from tests/issue_36.t rename to tests/test_issue_36.t diff --git a/tests/issue_37.t b/tests/test_issue_37.t similarity index 100% rename from tests/issue_37.t rename to tests/test_issue_37.t diff --git a/tests/issue_42.t b/tests/test_issue_42.t similarity index 100% rename from tests/issue_42.t rename to tests/test_issue_42.t diff --git a/tests/test_issue_480.t b/tests/test_issue_480.t new file mode 100644 index 000000000..11ef15504 --- /dev/null +++ b/tests/test_issue_480.t @@ -0,0 +1,59 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; + +# Load MySQLTuner +require './mysqltuner.pl'; +require './tests/MySQLTuner/TestHelper.pm'; + +# Force redefinition of essential subs +no warnings 'redefine'; +*main::execute_system_command = sub { return ""; }; +*main::which = sub { return "/usr/bin/mysql" }; +*main::infoprint = sub { }; +*main::goodprint = sub { }; +*main::badprint = sub { }; +*main::subheaderprint = sub { }; +*main::debugprint = sub { }; + +# Mock globals +$main::good = '[OK]'; +$main::bad = '[!!]'; +$main::info = '[--]'; +$main::deb = '[DG]'; +$main::end = ''; +our %myvar; +our %mystat; +our %mycalc; +our @adjvars; + +subtest 'table_open_cache_instances recommendation' => sub { + MySQLTuner::TestHelper::reset_state(); + + no warnings 'redefine'; + local *main::logical_cpu_cores = sub { return 8 }; + local *main::select_one = sub { + my $q = shift; + return 100 if $q =~ /COUNT\(\*\)/; + return 0; + }; + + main::mysql_stats(); + + ok(grep(/table_open_cache_instances \(=\s*4\)/, @main::adjvars), 'Suggested 4 instances for 8 CPU cores'); + + MySQLTuner::TestHelper::reset_state(); + $main::mycalc{'table_cache_hit_rate'} = 10; + local *main::logical_cpu_cores = sub { return 64 }; + local *main::select_one = sub { + my $q = shift; + return 100 if $q =~ /COUNT\(\*\)/; + return 0; + }; + main::mysql_stats(); + ok(grep(/table_open_cache_instances \(=\s*16\)/, @main::adjvars), 'Suggested max 16 instances for 64 CPU cores'); +}; + +done_testing(); diff --git a/tests/repro_issue_490.t b/tests/test_issue_490.t similarity index 100% rename from tests/repro_issue_490.t rename to tests/test_issue_490.t diff --git a/tests/test_issue_517.t b/tests/test_issue_517.t new file mode 100644 index 000000000..014c935e3 --- /dev/null +++ b/tests/test_issue_517.t @@ -0,0 +1,168 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +# Mock environment - set BEFORE require +$main::devnull = '/dev/null'; +$main::is_win = 0; + +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); + +{ + local @ARGV = (); + no warnings 'redefine'; + require $script; +} + +# Mock helper functions to avoid printing or exiting +{ + no warnings 'redefine'; + *main::infoprint = sub { diag "INFO: $_[0]" }; + *main::badprint = sub { diag "BAD: $_[0]" }; + *main::goodprint = sub { diag "GOOD: $_[0]" }; + *main::debugprint = sub { diag "DEBUG: $_[0]" }; + *main::subheaderprint = sub { diag "SUBHEADER: $_[0]" }; + *main::get_pf_memory = sub { return 0 }; + *main::get_gcache_memory = sub { return 0 }; + *main::mysql_cloud_discovery = sub { return "none" }; + *main::pretty_uptime = sub { return "1 day" }; + *main::get_other_process_memory = sub { return 0 }; + *main::select_array = sub { return () }; + *main::select_one = sub { return 0 }; + *main::execute_system_command = sub { return "" }; + eval '*main::is_remote = sub () { return 0; };'; +} + +sub get_base_mock_vars { + return ( + 'version' => '5.7.35', + 'version_comment' => 'MySQL Community Server', + 'read_buffer_size' => 1024, + 'read_rnd_buffer_size' => 1024, + 'sort_buffer_size' => 1024, + 'thread_stack' => 1024, + 'join_buffer_size' => 1024, + 'binlog_cache_size' => 1024, + 'tmp_table_size' => 1024, + 'max_heap_table_size' => 1024, + 'max_connections' => 10, + 'key_buffer_size' => 5000, + 'innodb_buffer_pool_size' => 10000, + 'innodb_additional_mem_pool_size' => 1024, + 'innodb_log_buffer_size' => 1024, + 'query_cache_size' => 64 * 1024 * 1024, + 'query_cache_type' => 'ON', + 'aria_pagecache_buffer_size' => 1024, + 'long_query_time' => 10, + 'log_bin' => 'OFF', + 'have_innodb' => 'YES', + 'open_files_limit' => 1024, + 'thread_cache_size' => 8, + 'concurrent_insert' => 'AUTO', + ); +} + +sub get_base_mock_stats { + return ( + 'Questions' => 1500, + 'Max_used_connections' => 5, + 'Uptime' => 86400, + 'Qcache_free_memory' => 32 * 1024 * 1024, + 'Qcache_hits' => 1000, + 'Com_select' => 1500, + 'Qcache_lowmem_prunes' => 0, + 'Connections' => 100, + 'Aborted_connects' => 0, + 'Key_read_requests' => 100, + 'Key_reads' => 0, + 'Key_write_requests' => 100, + 'Key_writes' => 0, + 'Slow_queries' => 0, + 'Key_blocks_unused' => 100, + 'Table_locks_immediate' => 100, + 'Table_locks_waited' => 0, + 'Created_tmp_tables' => 10, + 'Opened_tables' => 10, + 'Open_tables' => 10, + 'Threads_cached' => 5, + 'Bytes_sent' => 1000, + 'Bytes_received' => 1000, + 'Threads_created' => 2, + ); +} + +subtest 'slow_query_log is OFF' => sub { + @main::generalrec = (); + @main::adjvars = (); + $main::physical_memory = 32 * 1024 * 1024 * 1024; + $main::swap_memory = 4 * 1024 * 1024 * 1024; + + %main::myvar = ( + get_base_mock_vars(), + slow_query_log => 'OFF', + log_slow_queries => 'ON', # Should be overridden by slow_query_log + ); + %main::mystat = ( + get_base_mock_stats(), + ); + %main::mycalc = (); + + eval { main::calculations(); main::mysql_stats(); }; + ok(!$@, 'calculations() did not crash') or diag("Crashed: $@"); + + my $found = grep { /Enable the slow query log/ } @main::generalrec; + ok($found, 'Should recommend enabling slow query log when slow_query_log is OFF'); +}; + +subtest 'slow_query_log is ON' => sub { + @main::generalrec = (); + @main::adjvars = (); + $main::physical_memory = 32 * 1024 * 1024 * 1024; + $main::swap_memory = 4 * 1024 * 1024 * 1024; + + %main::myvar = ( + get_base_mock_vars(), + slow_query_log => 'ON', + log_slow_queries => 'OFF', # Should be overridden by slow_query_log + ); + %main::mystat = ( + get_base_mock_stats(), + ); + %main::mycalc = (); + + eval { main::calculations(); main::mysql_stats(); }; + ok(!$@, 'calculations() did not crash') or diag("Crashed: $@"); + + my $found = grep { /Enable the slow query log/ } @main::generalrec; + ok(!$found, 'Should not recommend enabling slow query log when slow_query_log is ON'); +}; + +subtest 'log_slow_queries fallback is OFF' => sub { + @main::generalrec = (); + @main::adjvars = (); + $main::physical_memory = 32 * 1024 * 1024 * 1024; + $main::swap_memory = 4 * 1024 * 1024 * 1024; + + %main::myvar = ( + get_base_mock_vars(), + log_slow_queries => 'OFF', + ); + %main::mystat = ( + get_base_mock_stats(), + ); + %main::mycalc = (); + + eval { main::calculations(); main::mysql_stats(); }; + ok(!$@, 'calculations() did not crash') or diag("Crashed: $@"); + + my $found = grep { /Enable the slow query log/ } @main::generalrec; + ok($found, 'Should recommend enabling slow query log when log_slow_queries fallback is OFF'); +}; + +done_testing(); diff --git a/tests/repro_issue_605.t b/tests/test_issue_605.t similarity index 100% rename from tests/repro_issue_605.t rename to tests/test_issue_605.t diff --git a/tests/issue_770.t b/tests/test_issue_770.t similarity index 100% rename from tests/issue_770.t rename to tests/test_issue_770.t diff --git a/tests/issue_774.t b/tests/test_issue_774.t similarity index 100% rename from tests/issue_774.t rename to tests/test_issue_774.t diff --git a/tests/issue_777.t b/tests/test_issue_777.t similarity index 100% rename from tests/issue_777.t rename to tests/test_issue_777.t diff --git a/tests/issue_781.t b/tests/test_issue_781.t similarity index 100% rename from tests/issue_781.t rename to tests/test_issue_781.t diff --git a/tests/issue_782.t b/tests/test_issue_782.t similarity index 100% rename from tests/issue_782.t rename to tests/test_issue_782.t diff --git a/tests/issue_783.t b/tests/test_issue_783.t similarity index 100% rename from tests/issue_783.t rename to tests/test_issue_783.t diff --git a/tests/repro_issue_863.t b/tests/test_issue_863.t similarity index 100% rename from tests/repro_issue_863.t rename to tests/test_issue_863.t diff --git a/tests/issue_863_enhanced.t b/tests/test_issue_863_enhanced.t similarity index 100% rename from tests/issue_863_enhanced.t rename to tests/test_issue_863_enhanced.t diff --git a/tests/issue_864.t b/tests/test_issue_864.t similarity index 100% rename from tests/issue_864.t rename to tests/test_issue_864.t diff --git a/tests/issue_869.t b/tests/test_issue_869.t similarity index 100% rename from tests/issue_869.t rename to tests/test_issue_869.t diff --git a/tests/issue_881_887.t b/tests/test_issue_881_887.t similarity index 100% rename from tests/issue_881_887.t rename to tests/test_issue_881_887.t diff --git a/tests/issue_888.t b/tests/test_issue_888.t similarity index 100% rename from tests/issue_888.t rename to tests/test_issue_888.t diff --git a/tests/issue_896.t b/tests/test_issue_896.t similarity index 100% rename from tests/issue_896.t rename to tests/test_issue_896.t diff --git a/tests/issue_904.t b/tests/test_issue_904.t similarity index 100% rename from tests/issue_904.t rename to tests/test_issue_904.t diff --git a/tests/issue_913.t b/tests/test_issue_913.t similarity index 100% rename from tests/issue_913.t rename to tests/test_issue_913.t diff --git a/tests/issue_923.t b/tests/test_issue_923.t similarity index 100% rename from tests/issue_923.t rename to tests/test_issue_923.t diff --git a/tests/unit_cli_helpers.t b/tests/unit_cli_helpers.t new file mode 100644 index 000000000..1644300c6 --- /dev/null +++ b/tests/unit_cli_helpers.t @@ -0,0 +1,153 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +our $mock_exit_active = 0; +BEGIN { + *CORE::GLOBAL::exit = sub { + my $code = shift // 0; + if ($mock_exit_active) { + die "MOCKED_EXIT: $code\n"; + } + CORE::exit($code); + }; +} + +$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; + +# Declare globals before loading script +our @adjvars; +our @generalrec; +our @modeling; +our @sysrec; +our @secrec; +our %opt; +our %myvar; +our %mystat; +our %mycalc; +our %result; +our $mysqlcmd; +our $devnull; + +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +{ + local @ARGV = (); + no warnings 'redefine'; + require $script; +} + +my @mock_output; +sub reset_mocks { + @mock_output = (); + @main::generalrec = (); + @main::adjvars = (); + @main::modeling = (); + @main::sysrec = (); + @main::secrec = (); + %main::result = (); + %main::opt = (); + %main::myvar = (); +} + +{ + no warnings 'redefine'; + *main::infoprint = sub { push @mock_output, "INFO: $_[0]" }; + *main::badprint = sub { push @mock_output, "BAD: $_[0]" }; + *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" }; + *main::debugprint = sub { }; + *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" }; + *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" }; +} + +# ===================================================================== +# 1. parse_cli_args +# ===================================================================== +subtest 'parse_cli_args' => sub { + reset_mocks(); + local @ARGV = ('--host', '192.168.1.100', '--user', 'tuner_user'); + main::parse_cli_args(); + is($main::opt{host}, '192.168.1.100', 'host option parsed'); + is($main::opt{user}, 'tuner_user', 'user option parsed'); +}; + +subtest 'parse_cli_args invalid option' => sub { + reset_mocks(); + local @ARGV = ('--invalid-opt-name-xyz'); + local $mock_exit_active = 1; + # Capture STDERR or redirect it to avoid clutter + open my $old_err, '>&', \*STDERR or die "Can't dup STDERR: $!"; + close STDERR; + open STDERR, '>', File::Spec->devnull() or die "Can't redirect STDERR: $!"; + eval { + main::parse_cli_args(); + }; + my $err = $@; + # Restore STDERR + open STDERR, '>&', $old_err or die "Can't restore STDERR: $!"; + close $old_err; + like($err, qr/MOCKED_EXIT: 1/, 'exit code 1 on invalid option'); +}; + +# ===================================================================== +# 2. show_help +# ===================================================================== +subtest 'show_help' => sub { + reset_mocks(); + local $mock_exit_active = 1; + my $help_output = ''; + # Capture STDOUT + open my $old_out, '>&', \*STDOUT or die "Can't dup STDOUT: $!"; + close STDOUT; + open STDOUT, '>', \$help_output or die "Can't redirect STDOUT: $!"; + eval { + main::show_help(); + }; + my $err = $@; + # Restore STDOUT + open STDOUT, '>&', $old_out or die "Can't restore STDOUT: $!"; + close $old_out; + like($err, qr/MOCKED_EXIT: 0/, 'exit code 0 on show_help'); + like($help_output, qr/Usage: \.\/mysqltuner\.pl/, 'help usage output present'); + like($help_output, qr/CONNECTION AND AUTHENTICATION/, 'help categories present'); +}; + +# ===================================================================== +# 3. get_http_cli +# ===================================================================== +subtest 'get_http_cli' => sub { + reset_mocks(); + # Case 1: curl exists + { + no warnings 'redefine'; + local *main::which = sub { + my ($cmd) = @_; + return "/usr/bin/curl\n" if $cmd eq 'curl'; + return ""; + }; + is(main::get_http_cli(), "/usr/bin/curl", 'curl detected'); + } + # Case 2: wget exists (curl does not) + { + no warnings 'redefine'; + local *main::which = sub { + my ($cmd) = @_; + return "/usr/bin/wget\n" if $cmd eq 'wget'; + return ""; + }; + is(main::get_http_cli(), "/usr/bin/wget", 'wget fallback works'); + } + # Case 3: neither exists + { + no warnings 'redefine'; + local *main::which = sub { return ""; }; + is(main::get_http_cli(), "", 'empty returned if neither exists'); + } +}; + +done_testing(); diff --git a/tests/unit_client_privileges.t b/tests/unit_client_privileges.t new file mode 100644 index 000000000..1086ebe3d --- /dev/null +++ b/tests/unit_client_privileges.t @@ -0,0 +1,119 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; + +# Declare globals before loading script +our @adjvars; +our @generalrec; +our @modeling; +our @sysrec; +our @secrec; +our %opt; +our %myvar; +our %mystat; +our %mycalc; +our %result; +our $mysqlcmd; + +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +{ + local @ARGV = (); + no warnings 'redefine'; + require $script; +} + +my @mock_output; +sub reset_mocks { + @mock_output = (); + @main::generalrec = (); + @main::adjvars = (); + @main::modeling = (); + @main::sysrec = (); + @main::secrec = (); + %main::result = (); + %main::opt = (); + %main::myvar = (); +} + +{ + no warnings 'redefine'; + *main::infoprint = sub { push @mock_output, "INFO: $_[0]" }; + *main::badprint = sub { push @mock_output, "BAD: $_[0]" }; + *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" }; + *main::debugprint = sub { }; + *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" }; + *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" }; +} + +# ===================================================================== +# 1. get_tuning_info +# ===================================================================== +subtest 'get_tuning_info' => sub { + reset_mocks(); + no warnings 'redefine'; + local *main::select_array = sub { + return ( + " Connection: localhost via UNIX socket\n", + " Server: Localhost via UNIX socket\n", + ); + }; + main::get_tuning_info(); + is($result{'MySQL Client'}{'Connection'}, 'localhost via UNIX socket', 'parsed client connection'); + is($result{'MySQL Client'}{'Client Path'}, $mysqlcmd, 'Client Path set'); +}; + +# ===================================================================== +# 2. check_privileges +# ===================================================================== +subtest 'check_privileges' => sub { + # Case 1: ALL PRIVILEGES + { + reset_mocks(); + no warnings 'redefine'; + local *main::select_array = sub { return ("GRANT ALL PRIVILEGES ON *.* TO 'root'\@'localhost'"); }; + main::check_privileges(); + is(scalar @mock_output, 0, 'No warning output when ALL PRIVILEGES present'); + } + # Case 2: SUPER privilege + { + reset_mocks(); + no warnings 'redefine'; + local *main::select_array = sub { return ("GRANT SUPER ON *.* TO 'root'\@'localhost'"); }; + main::check_privileges(); + is(scalar @mock_output, 0, 'No warning output when SUPER present'); + } + # Case 3: Missing privileges on MySQL 8.0 + { + reset_mocks(); + $main::myvar{'version'} = '8.0.35'; + no warnings 'redefine'; + local *main::select_array = sub { return ("GRANT SELECT, PROCESS ON *.* TO 'user'\@'localhost'"); }; + local *main::mysql_version_ge = sub { return 1; }; + main::check_privileges(); + ok(grep({ /missing the following privileges/ } @mock_output), 'Warning print on missing privileges'); + } + # Case 4: Missing privileges on MariaDB 10.5 + { + reset_mocks(); + $main::myvar{'version'} = '10.5.25-MariaDB'; + no warnings 'redefine'; + local *main::select_array = sub { return ("GRANT SELECT, PROCESS ON *.* TO 'user'\@'localhost'"); }; + local *main::mysql_version_ge = sub { + my ($major, $minor) = @_; + return 1 if $major == 10 && $minor == 5; + return 0; + }; + main::check_privileges(); + ok(grep({ /missing the following privileges/ } @mock_output), 'Warning print on missing MariaDB privileges'); + } +}; + +done_testing(); diff --git a/tests/unit_cloud_commands.t b/tests/unit_cloud_commands.t new file mode 100644 index 000000000..a15a1e6e2 --- /dev/null +++ b/tests/unit_cloud_commands.t @@ -0,0 +1,133 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; + +# Declare globals before loading script +our @adjvars; +our @generalrec; +our @modeling; +our @sysrec; +our @secrec; +our %opt; +our %myvar; +our %mystat; +our %mycalc; +our %result; + +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +{ + local @ARGV = (); + no warnings 'redefine'; + require $script; +} + +my @mock_output; +sub reset_mocks { + @mock_output = (); + @main::generalrec = (); + @main::adjvars = (); + @main::modeling = (); + @main::sysrec = (); + @main::secrec = (); + %main::result = (); + %main::opt = (); + %main::myvar = (); +} + +{ + no warnings 'redefine'; + *main::infoprint = sub { push @mock_output, "INFO: $_[0]" }; + *main::badprint = sub { push @mock_output, "BAD: $_[0]" }; + *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" }; + *main::debugprint = sub { }; + *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" }; + *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" }; +} + +# ===================================================================== +# 1. infoprintcmd & infoprinthcmd +# ===================================================================== +subtest 'infoprintcmd & infoprinthcmd' => sub { + reset_mocks(); + my @cmd_prints; + my @info_prints; + my @header_prints; + no warnings 'redefine'; + local *main::cmdprint = sub { push @cmd_prints, $_[0] }; + local *main::infoprintml = sub { push @info_prints, @_ }; + local *main::subheaderprint = sub { push @header_prints, $_[0] }; + + main::infoprintcmd("echo 'test_infoprintcmd'"); + is(scalar @cmd_prints, 1, 'cmdprint called once'); + like($cmd_prints[0], qr/echo 'test_infoprintcmd'/, 'cmdprint received command'); + ok(grep({ /test_infoprintcmd/ } @info_prints), 'infoprintml printed output of command'); + + main::infoprinthcmd("My Header", "echo 'test_infoprinthcmd'"); + is($header_prints[0], "My Header", 'subheaderprint called with header'); + like($cmd_prints[1], qr/echo 'test_infoprinthcmd'/, 'infoprintcmd called inside infoprinthcmd'); +}; + +# ===================================================================== +# 2. cloud_setup +# ===================================================================== +subtest 'cloud_setup' => sub { + # Case 1: direct connection mode + { + reset_mocks(); + $main::opt{cloud} = 1; + $main::opt{'ssh-host'} = undef; + $main::opt{forcemem} = 0; + main::cloud_setup(); + is($main::opt{cloud}, 1, 'cloud is 1'); + is($main::opt{nosysstat}, 1, 'nosysstat is set to 1 in direct mode'); + is($main::opt{forcemem}, 1024, 'forcemem defaults to 1024'); + } + # Case 2: ssh connection mode with command outputs + { + reset_mocks(); + $main::opt{cloud} = 1; + $main::opt{'ssh-host'} = 'my-remote-db'; + $main::opt{forcemem} = 0; + $main::opt{forceswap} = 0; + no warnings 'redefine'; + local *main::execute_system_command = sub { + my ($cmd) = @_; + if ($cmd =~ /uname/) { + return "Linux remote-host 5.4.0\n"; + } + elsif ($cmd =~ /MemTotal/) { + return "MemTotal: 4194304 kB\n"; # 4GB + } + elsif ($cmd =~ /SwapTotal/) { + return "SwapTotal: 2097152 kB\n"; # 2GB + } + return ""; + }; + main::cloud_setup(); + is($main::opt{forcemem}, 4096, 'remote memory detected and set'); + is($main::opt{forceswap}, 2048, 'remote swap detected and set'); + } + # Case 3: ssh connection mode but commands fail (default fallback) + { + reset_mocks(); + $main::opt{cloud} = 1; + $main::opt{'ssh-host'} = 'my-remote-db'; + $main::opt{forcemem} = 0; + $main::opt{forceswap} = 0; + no warnings 'redefine'; + local *main::execute_system_command = sub { return (); }; + main::cloud_setup(); + is($main::opt{forcemem}, 1024, 'forcemem fallback to 1024'); + is($main::opt{forceswap}, 0, 'forceswap fallback to 0'); + } +}; + +done_testing(); diff --git a/tests/unit_fs_info.t b/tests/unit_fs_info.t new file mode 100644 index 000000000..2ac3d2b65 --- /dev/null +++ b/tests/unit_fs_info.t @@ -0,0 +1,103 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; + +# Declare globals before loading script +our @adjvars; +our @generalrec; +our @modeling; +our @sysrec; +our @secrec; +our %opt; +our %myvar; +our %mystat; +our %mycalc; +our %result; + +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +{ + local @ARGV = (); + no warnings 'redefine'; + require $script; +} + +my @mock_output; +sub reset_mocks { + @mock_output = (); + @main::generalrec = (); + @main::adjvars = (); + @main::modeling = (); + @main::sysrec = (); + @main::secrec = (); + %main::result = (); + %main::opt = (); + %main::myvar = (); +} + +{ + no warnings 'redefine'; + *main::infoprint = sub { push @mock_output, "INFO: $_[0]" }; + *main::badprint = sub { push @mock_output, "BAD: $_[0]" }; + *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" }; + *main::debugprint = sub { }; + *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" }; + *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" }; +} + +# ===================================================================== +# 1. get_fs_info & get_fs_info_win +# ===================================================================== +subtest 'get_fs_info' => sub { + reset_mocks(); + no warnings 'redefine'; + local *main::execute_system_command = sub { + my ($cmd) = @_; + if ($cmd =~ /df -P/) { + return ( + "Filesystem 1024-blocks Used Available Capacity Mounted on\n", + "/dev/sda1 10485760 9437184 1048576 90% /\n", # >85% + "/dev/sda2 10485760 5242880 5242880 50% /home\n", # <=85% + ); + } + elsif ($cmd =~ /df -Pi/) { + return ( + "Filesystem Inodes IUsed IFree IUse% Mounted on\n", + "/dev/sda1 1000000 900000 100000 90% /\n", # >85% + "/dev/sda2 1000000 500000 500000 50% /home\n", # <=85% + ); + } + return (); + }; + main::get_fs_info(); + is($result{'Filesystem'}{'Space Pct'}{'/'}, 90, 'root Space Pct parsed'); + is($result{'Filesystem'}{'Space Pct'}{'/home'}, 50, 'home Space Pct parsed'); + is($result{'Filesystem'}{'Inode Pct'}{'/'}, 90, 'root Inode Pct parsed'); + ok(grep({ /mount point \/ is using 90 % total space/ } @mock_output), 'warning on high disk usage printed'); + ok(grep({ /mount point \/ is using 90 % of max allowed inodes/ } @mock_output), 'warning on high inode usage printed'); +}; + +subtest 'get_fs_info_win' => sub { + reset_mocks(); + no warnings 'redefine'; + local *main::execute_system_command = sub { + return ( + "FreeSpace Name Size\n", + "1073741824 C: 10737418240\n", # 90% used + "5368709120 D: 10737418240\n", # 50% used + ); + }; + main::get_fs_info_win(); + is($result{'Filesystem'}{'Space Pct'}{'C:'}, 90, 'Disk C Space Pct parsed'); + is($result{'Filesystem'}{'Space Pct'}{'D:'}, 50, 'Disk D Space Pct parsed'); + ok(grep({ /Disk C: is using 90 % total space/ } @mock_output), 'warning on high windows disk usage printed'); +}; + +done_testing(); diff --git a/tests/unit_os_vm.t b/tests/unit_os_vm.t new file mode 100644 index 000000000..9591f5b78 --- /dev/null +++ b/tests/unit_os_vm.t @@ -0,0 +1,114 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; +use Cwd 'abs_path'; + +$SIG{__WARN__} = sub { warn $_[0] unless $_[0] =~ /redefined/ }; + +# Declare globals before loading script +our @adjvars; +our @generalrec; +our @modeling; +our @sysrec; +our @secrec; +our %opt; +our %myvar; +our %mystat; +our %mycalc; +our %result; + +my $script_dir = dirname(abs_path(__FILE__)); +my $script = abs_path(File::Spec->catfile($script_dir, '..', 'mysqltuner.pl')); +{ + local @ARGV = (); + no warnings 'redefine'; + require $script; +} + +my @mock_output; +sub reset_mocks { + @mock_output = (); + @main::generalrec = (); + @main::adjvars = (); + @main::modeling = (); + @main::sysrec = (); + @main::secrec = (); + %main::result = (); + %main::opt = (); + %main::myvar = (); +} + +{ + no warnings 'redefine'; + *main::infoprint = sub { push @mock_output, "INFO: $_[0]" }; + *main::badprint = sub { push @mock_output, "BAD: $_[0]" }; + *main::goodprint = sub { push @mock_output, "GOOD: $_[0]" }; + *main::debugprint = sub { }; + *main::subheaderprint = sub { push @mock_output, "HEADER: $_[0]" }; + *main::prettyprint = sub { push @mock_output, "PRETTY: $_[0]" }; +} + +# ===================================================================== +# 1. get_os_release +# ===================================================================== +subtest 'get_os_release' => sub { + reset_mocks(); + my $release = main::get_os_release(); + ok(defined $release && $release ne '', "get_os_release returns some string: $release"); +}; + +# ===================================================================== +# 2. is_virtual_machine +# ===================================================================== +subtest 'is_virtual_machine' => sub { + # Case 1: Linux with ssh prefix + { + reset_mocks(); + local $^O = 'linux'; + no warnings 'redefine'; + local *main::get_transport_prefix = sub { return 'ssh'; }; + local *main::execute_system_command = sub { + my ($cmd) = @_; + return 1 if $cmd =~ /grep -Ec/; + return 0; + }; + is(main::is_virtual_machine(), 1, 'Virtual machine detected via ssh hypervisor grep'); + } + # Case 2: Linux local check + { + reset_mocks(); + local $^O = 'linux'; + no warnings 'redefine'; + local *main::get_transport_prefix = sub { return ''; }; + my $is_vm = main::is_virtual_machine(); + ok(defined $is_vm, 'Local VM detection check executed'); + } + # Case 3: FreeBSD check + { + reset_mocks(); + local $^O = 'freebsd'; + no warnings 'redefine'; + local *main::execute_system_command = sub { + my ($cmd) = @_; + return "bhyve\n" if $cmd =~ /sysctl/; + return "none\n"; + }; + is(main::is_virtual_machine(), 1, 'FreeBSD VM detected'); + } + # Case 4: Windows check + { + reset_mocks(); + local $main::is_win = 1; + no warnings 'redefine'; + local *main::execute_system_command = sub { + return "System Model: Virtual Machine\n"; + }; + is(main::is_virtual_machine(), 1, 'Windows VM model detected'); + } +}; + +done_testing(); From 0b5c8590534bde701e0f8521fcfc1e90e333f711 Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Wed, 24 Jun 2026 20:04:34 +0200 Subject: [PATCH 16/16] docs: regenerate release notes --- releases/v2.9.0.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/releases/v2.9.0.md b/releases/v2.9.0.md index c07924ef5..fbd12f549 100644 --- a/releases/v2.9.0.md +++ b/releases/v2.9.0.md @@ -31,6 +31,11 @@ - test(lab): utilize isolated tempfile for mock json to avoid race conditions in tests/unit_phase13_kpis.t. - test(report): add verbose timing and audit snapshot summary formatting checks to tests/verbose_timing.t. - test(versions): add unit tests for version caching and comparisons, resolve redundant warnings, and use tempfile in tests/unit_versions.t. +- feat(main): recommend enabling slow query log if disabled (#517) +- test(lab): add unit test test_issue_517.t for slow query log recommendations (#517) +- test(lab): add unit test test_issue_480.t for table_open_cache_instances recommendation (#480) +- test(lab): split unit_coverage_boost4.t into smaller topic-oriented unit tests: unit_cli_helpers.t, unit_client_privileges.t, unit_cloud_commands.t, unit_fs_info.t, unit_os_vm.t +- test(lab): normalize all repro_issue_*.t and issue_*.t test file names to test_issue_*.t ``` ## 📈 Diagnostic Growth Indicators @@ -44,6 +49,10 @@ ## 🛠️ Internal Commit History +- feat: recommend slow query log when disabled (#517) (da6d437) +- docs: add LightPath as sponsor, relocate coffee button, and use star-history chart (31e3638) +- docs: update repository links to major/MySQLTuner-perl and add GitHub stars badge (4cd208e) +- docs: regenerate release notes (a754147) - fix(main): add undefined/NULL guards to hr_num to resolve uninitialized warnings (0a87482) - docs(roadmap): link strategic technical evolutions specification and enforce changelog staging (136a977) - docs: regenerate release notes (abe582a)