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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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/53] 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) From 6ece0c310e2c8a60e8fc77fc0d7eff65c78bb0d8 Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Fri, 26 Jun 2026 10:28:06 +0200 Subject: [PATCH 17/53] test(lab): add unit test test_issue_810.t for issue 810 --- Changelog | 5 ++++ tests/test_issue_810.t | 63 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 tests/test_issue_810.t diff --git a/Changelog b/Changelog index f34c6e006..5160d188c 100644 --- a/Changelog +++ b/Changelog @@ -2,6 +2,8 @@ 2.9.0 2026-06-15 +- feat(report): categorize recommendations in HTML report and add Connections/Performance panels, Storage engines table, and SQL modeling stats grid. +- test(report): update HTML report unit tests to verify connections, performance, storage, and modeling HTML sections. - 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. @@ -31,6 +33,9 @@ - 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 +- test(lab): add unit test test_issue_810.t for remote host forcemem MB interpretation correctness (issue #810) +- docs(roadmap): specify Phase XVI (AI Agent Integration) and Phase XVII (Dockerized MCP Daemon). + 2.8.45 2026-06-04 diff --git a/tests/test_issue_810.t b/tests/test_issue_810.t new file mode 100644 index 000000000..b7d977a91 --- /dev/null +++ b/tests/test_issue_810.t @@ -0,0 +1,63 @@ +#!/usr/bin/env perl +use strict; +use warnings; +no warnings 'once'; +use Test::More; +use File::Basename; +use File::Spec; + +my $script = File::Spec->rel2abs(File::Spec->catfile(dirname(__FILE__), '..', 'mysqltuner.pl')); + +# Mocking and loading mysqltuner.pl +{ + local @ARGV = (); + no warnings 'redefine'; + require $script; +} + +# Silence console printing from within the test +{ + no warnings 'redefine'; + no warnings 'once'; + *main::badprint = sub { }; + *main::goodprint = sub { }; + *main::debugprint = sub { }; + *main::infoprint = sub { }; + *main::subheaderprint = sub { }; + *main::prettyprint = sub { }; +} + +# Mock transport and system commands +{ + no warnings 'redefine'; + no warnings 'once'; + *main::get_transport_prefix = sub { return '' }; + *main::execute_system_command = sub { return '' }; +} + +subtest 'Issue 810 - Verify forcemem MB interpretation correctness (regression test)' => sub { + # Initialize basic CLI options metadata defaults + foreach my $o (keys %main::CLI_METADATA) { + my ($p) = split /\|/, $o; + $p =~ s/[!+=:].*$//; + $main::opt{$p} //= $main::CLI_METADATA{$o}->{default}; + } + + # Simulate `--forcemem 32768` (passed in MB for a 32GB system) + $main::opt{'forcemem'} = 32768; + $main::opt{'forceswap'} = 0; + + # Run OS setup logic to calculate physical memory + main::os_setup(); + + # Verification: + # 32768 MB must equal 34,359,738,368 bytes (32 GB) + is($main::physical_memory, 34359738368, "Physical memory must be correctly calculated in bytes (32768 * 1024 * 1024)"); + + # Verify that human readable formatting outputs 32.0G and NOT 32.0K (which occurred before the regression fix) + my $pretty = main::hr_bytes($main::physical_memory); + is($pretty, "32.0G", "Pretty memory output must be 32.0G"); + isnt($pretty, "32.0K", "Pretty memory output must not be 32.0K (regression check)"); +}; + +done_testing(); From 7fd2391a7455251c036b3879c50646441d6d3fa2 Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Fri, 26 Jun 2026 10:28:06 +0200 Subject: [PATCH 18/53] 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 fbd12f549..3607e50fe 100644 --- a/releases/v2.9.0.md +++ b/releases/v2.9.0.md @@ -7,6 +7,8 @@ ```text 2.9.0 2026-06-15 +- feat(report): categorize recommendations in HTML report and add Connections/Performance panels, Storage engines table, and SQL modeling stats grid. +- test(report): update HTML report unit tests to verify connections, performance, storage, and modeling HTML sections. - 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. @@ -36,6 +38,8 @@ - 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 +- test(lab): add unit test test_issue_810.t for remote host forcemem MB interpretation correctness (issue #810) +- docs(roadmap): specify Phase XVI (AI Agent Integration) and Phase XVII (Dockerized MCP Daemon). ``` ## 📈 Diagnostic Growth Indicators @@ -49,6 +53,8 @@ ## 🛠️ Internal Commit History +- test(lab): add unit test test_issue_810.t for issue 810 (6ece0c3) +- docs: regenerate release notes (0b5c859) - 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) From 1008ba4291d42a639c729577633f500052c632fe Mon Sep 17 00:00:00 2001 From: Jean-Marie Renouard Date: Fri, 26 Jun 2026 10:40:01 +0200 Subject: [PATCH 19/53] fix: update documentation and code --- .agent/rules/03_execution_rules.md | 9 +- Changelog | 1 - POTENTIAL_ISSUES.md | 10 + ROADMAP.md | 29 + SECURITY.md | 2 +- .../roadmap_phase_xiv_html_reports.md | 53 + .../roadmap_phase_xv_ai_agent_integration.md | 131 ++ .../roadmap_phase_xvi_mcp_server.md | 71 + mysqltuner.pl | 1712 ++++++++++++++--- tests/html_report.t | 79 +- 10 files changed, 1841 insertions(+), 256 deletions(-) create mode 100644 documentation/specifications/roadmap_phase_xiv_html_reports.md create mode 100644 documentation/specifications/roadmap_phase_xv_ai_agent_integration.md create mode 100644 documentation/specifications/roadmap_phase_xvi_mcp_server.md diff --git a/.agent/rules/03_execution_rules.md b/.agent/rules/03_execution_rules.md index c23357e43..ded92a982 100644 --- a/.agent/rules/03_execution_rules.md +++ b/.agent/rules/03_execution_rules.md @@ -76,11 +76,10 @@ To ensure quality and clarity in every development cycle, all non-trivial featur - **Commit Validation:** Commits are automatically linted via `commitlint`. Non-compliant messages will be rejected by the pre-commit hook. - **History Documentation:** Use `npm run commit` to generate structured history. -1. **Changelog:** All changes MUST be traced and documented inside `@Changelog`. - - _Exception_: Documentation-only updates (`docs:`) following Conventional Commits may skip the manual `@Changelog` entry if they are primarily intended for README synchronization. - - _Requirement_: Adding a new test MUST have a `test:` entry in the `@Changelog`. - - _Requirement_: Changing test scripts or updating infrastructure MUST have a `ci:` entry in the `@Changelog`. - - _Requirement_: Changing `Makefile` or files under `build/` MUST be traced in the `@Changelog` (usually via `ci:` or `chore:`). +1. **Changelog:** All changes, without exception (including documentation `docs:` and unit tests `test:`), MUST be traced and documented inside the `Changelog` file to ensure granular change tracking. + - _Requirement_: Adding a new test MUST have a `test:` entry in the `Changelog`. + - _Requirement_: Changing test scripts or updating infrastructure MUST have a `ci:` entry in the `Changelog`. + - _Requirement_: Changing `Makefile` or files under `build/` MUST be traced in the `Changelog` (usually via `ci:` or `chore:`). - _Requirement_: All feature completions MUST be synchronized with `ROADMAP.md` before final PR/Commit. - _Ordering_: Changelog entries MUST be ordered by category: `chore`, `feat`, `fix`, `test`, `ci`, then others. - _Release Notes_: All release notes generated in `releases/` MUST follow the same category ordering in their "Executive Summary" section. diff --git a/Changelog b/Changelog index 5160d188c..796052185 100644 --- a/Changelog +++ b/Changelog @@ -37,7 +37,6 @@ - docs(roadmap): specify Phase XVI (AI Agent Integration) and Phase XVII (Dockerized MCP Daemon). - 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 2ad5ef2eb..c85c11739 100644 --- a/POTENTIAL_ISSUES.md +++ b/POTENTIAL_ISSUES.md @@ -212,3 +212,13 @@ This file records anomalies discovered during laboratory testing (Perl warnings, - [x] **Doc-Sync**: `.agent/README.md` synchronized with 18 workflows. - [x] **SECURITY.md**: Version reference updated to v2.8.44. - [x] **ROADMAP PI-010**: I/O Pressure status already corrected. + +### [2026-06-04] Release v2.8.45 + +- [x] **Temptable Sizing Limits**: Integrated `temptable_max_ram` calculations and mmap checks. +- [x] **InnoDB Index/Data Ratio Check**: Added advisory and CSV dump for tables > 50,000 rows. +- [x] **Uptime Observability**: Exposed Database Server Uptime in all modes. +- [x] **Performance Optimization**: Bulk-fetched table engine and column details to reduce queries (from 2N+3 to 2 per table). +- [x] **System DB Filtering**: Excluded system tables from schema analysis and dumpdir exports. +- [x] **SQL Escaping Fixes**: Safe dollar sign escaping in system call wrappers. + diff --git a/ROADMAP.md b/ROADMAP.md index b82913430..8b5708541 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -231,6 +231,35 @@ 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. +### [Phase 15: Interactive Multi-Page HTML Reports & Detailed Exports](file:///documentation/specifications/roadmap_phase_xiv_html_reports.md) [COMPLETED] + +* [x] **Summary Page Dashboard**: + * [x] Executive summary layout with a modern circular health score gauge, category scores breakdown, and top findings. +* [x] **Topic-Based Metrics Partitioning**: + * [x] Structure the report into tabs/views: Memory, Connections, Storage Engines, Performance, Security, SQL Modeling, Replication. +* [x] **SVG/CSS-Based Ratios Visualization**: + * [x] Render interactive bars/gauges for InnoDB buffer pool hit rate, thread cache hit rate, disk temp tables, and connection saturation. +* [x] **Embedded CSV Data Exports**: + * [x] Embed base64 or raw string CSV representation of variables and findings in JavaScript, enabling instant local CSV downloads. + +### [Phase 16: AI Agent Integration & Actionable JSON Schema](file:///documentation/specifications/roadmap_phase_xv_ai_agent_integration.md) [NOT STARTED] + +* [ ] **Structured Actionable JSON Output**: + * [ ] Implementation of `--agent-json` flag returning a standardized schema. +* [ ] **Expected Outcomes & Rollback Statements**: + * [ ] Each recommendation includes explicit expected outcome description and corresponding rollback statement. +* [ ] **Risk Assessment & Impact Scoring**: + * [ ] Assign deterministic impact score (1-10) and category/risk level to each recommendation. + +### [Phase 17: Dockerized Auditing Daemon & MCP Server Support](file:///documentation/specifications/roadmap_phase_xvi_mcp_server.md) [NOT STARTED] + +* [ ] **Interval Auditing Daemon**: + * [ ] Dockerized execution environment running auditing loops every X hours with caching. +* [ ] **Model Context Protocol (MCP) Server**: + * [ ] Expose caching layer, latest results, and immediate auditing as MCP tools and resources. +* [ ] **Safe execution & Rollbacks**: + * [ ] Implement secure database interaction tools to apply or rollback recommendations. + ## 🔮 [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. diff --git a/SECURITY.md b/SECURITY.md index 0ec300acf..99c12f082 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,7 @@ We provide security updates for the following versions of MySQLTuner: | Version | Status | | ------- | --------------------- | -| v2.x | Supported (v2.8.44) | +| v2.x | Supported (v2.9.0) | | < v2.x | End of Life | We strongly recommend that all users stay updated with the latest stable release available on [GitHub Releases](https://github.com/jmrenouard/MySQLTuner-perl/releases). diff --git a/documentation/specifications/roadmap_phase_xiv_html_reports.md b/documentation/specifications/roadmap_phase_xiv_html_reports.md new file mode 100644 index 000000000..a6ec3819a --- /dev/null +++ b/documentation/specifications/roadmap_phase_xiv_html_reports.md @@ -0,0 +1,53 @@ +# Specification: Roadmap Phase XV - Interactive Multi-Page HTML Reports & Detailed Exports + +- **Feature Name**: Interactive Multi-Page HTML Reports & Detailed Exports +- **Status**: Draft +- **Created Date**: 2026-06-25 + +## 🧠 Rationale + +As MySQLTuner-perl reports grow in complexity, a single long scrolling page of recommendations is no longer sufficient for database administrators and managers. Users need: +1. A **Summary Page (Dashboard)** providing an executive overview of overall health and key high-level indicators. +2. Metrics organized logically by **Topics** (e.g. Memory, Connections, Storage Engines, Performance, Security, SQL Modeling, Replication). +3. **Visual Graphs and Charts** (CSS/SVG based) to instantly understand critical ratios (e.g., InnoDB Buffer Pool hit rate, Thread cache hit rate, Memory allocation, and Temp tables on disk vs memory). +4. **Structured Tables** displaying raw variables, current status, and recommended thresholds side-by-side. +5. **CSV Data Exports** embedded directly within the report to allow administrators to download detailed diagnostic data as CSV for spreadsheet analysis, keeping the HTML report self-contained with zero external dependencies. + +This new phase enhances reporting to be interactive, visually stunning, and highly professional while retaining the zero-dependency, single-file architecture of `mysqltuner.pl`. + +## 🛠️ User Scenarios + +### Scenario 1: Executive Review of Database Health +A database manager runs MySQLTuner to get a quick summary. They open the generated HTML report and see a **Summary Dashboard Page** with a circular health gauge, key KPIs, resource saturation indicators, and the top findings across all areas. They do not have to dig through technical logs to assess general health. + +### Scenario 2: Topic-Specific Deep Dive with Graphs +A database administrator (DBA) is troubleshooting InnoDB performance. They open the report, click on the **Storage Engines & InnoDB** tab/page, and view: +- An SVG chart of the buffer pool hit rate. +- A table listing InnoDB status variables, current values, and recommended settings. +- A prioritized list of recommendations for InnoDB. + +### Scenario 3: Exporting Raw Diagnostic Data to CSV +A developer wants to import the parsed database metrics into Excel to perform a custom analysis. They go to the report's **Data Export** tab, see options to download separate CSVs (e.g., variables, status, schema findings, security settings), click "Download CSV", and instantly save the files locally. + +## 📋 User Stories + +| Title | Priority | Description | Rationale | Test Case | +| :--- | :--- | :--- | :--- | :--- | +| Summary Dashboard | P1 | As a database manager, I want a high-level summary dashboard page | So that I can quickly assess overall database health at a glance | Open HTML report, verify summary gauge and KPIs are displayed. | +| Categorized Topics | P1 | As a DBA, I want metrics and recommendations organized by topics | So that I can focus on one specific area (e.g., InnoDB, Security) without distraction | Verify separate tabs/pages for Memory, Connections, Storage, Performance, Security, SQL Modeling, and Replication. | +| Dynamic Graphs | P2 | As a DBA, I want SVG/CSS-based graphs for key ratios | So that I can visually identify saturation or efficiency bottlenecks | Check if InnoDB Buffer Pool utilization and Temp Table on Disk ratios are rendered as visual charts/bars. | +| Structured Tables | P1 | As a developer, I want metrics in formatted tables with recommended values | So that I can compare my current configuration against recommended thresholds | Check table displays for major metrics (e.g. key_buffer_size, max_connections) with current/recommended values. | +| Embedded CSV Downloads | P1 | As an analyst, I want to download detailed data as CSV files directly from the HTML report | So that I can analyze the findings in spreadsheet software without external files | Verify "Download CSV" buttons trigger browser-initiated CSV downloads containing the parsed raw values. | + +## ✅ Verification Plan + +### Manual Verification +1. Run `perl mysqltuner.pl --reportfile=mysqltuner_advanced.html` against a local or containerized database. +2. Open the HTML report in a modern web browser. +3. Verify that the interactive tabs switch between topics correctly. +4. Verify that visual charts/bars render accurately without error. +5. Click on the "Download CSV" buttons and ensure CSV files are generated and downloaded with the correct headers and content. + +### Automated Tests +1. Verify that `--reportfile` option works and generates the new file content structure. +2. Add a unit test or check logic inside the test suite to verify that the generated HTML file contains the Javascript block for CSV generation and the topic structure. diff --git a/documentation/specifications/roadmap_phase_xv_ai_agent_integration.md b/documentation/specifications/roadmap_phase_xv_ai_agent_integration.md new file mode 100644 index 000000000..f508d3f38 --- /dev/null +++ b/documentation/specifications/roadmap_phase_xv_ai_agent_integration.md @@ -0,0 +1,131 @@ +# Specification: Roadmap Phase XVI - AI Agent Integration & Actionable JSON Schema + +- **Feature Name**: AI Agent Integration & Actionable JSON Schema +- **Status**: Draft +- **Created Date**: 2026-06-25 + +## 🧠 Rationale + +As database operations become increasingly automated by AI agents (e.g., MCP-based workflows, specialized IDE agents, autonomous DBAs), standard human-readable terminal output and even simple YAML/JSON key-value exports are insufficient. + +To safely enable an AI agent to act on database advisor findings, the agent needs: +1. **Machine-Ingestible Action Schema**: Structured recommendations that specify the exact SQL statements (DDL/DML) or configuration adjustments required. +2. **Deterministic Rollback Action**: The precise inverse command to undo the action in case of performance regressions or unexpected failure. +3. **Safety & Risk Categorization**: An explicit risk assessment (e.g., whether a table lock is required, if a restart is needed) and an impact score/topic. +4. **Expected Outcomes**: Declarative descriptions of what the change will improve (e.g., memory usage reduction, cache hit rate increase). + +This phase adds a robust `--agent-json` output flag to `mysqltuner.pl` that outputs a highly structured, machine-actionable JSON payload. + +## 🛠️ User Scenarios + +### Scenario 1: Autonomous Tuning Agent +An AI agent connects to a database via an MCP server, runs MySQLTuner with `--agent-json`, parses the recommendations, and automatically applies P1 (high impact, low risk) recommendations using the provided `sql_statement`. + +### Scenario 2: Human-in-the-Loop Safe Rollback +An AI agent proposes a schema optimization (e.g. dropping a redundant index). It shows the user the description, expected outcome, and the provided `rollback_statement`. If the user consents, the agent executes the change. If a regression occurs later, the agent executes the `rollback_statement` to revert to the baseline. + +## 📋 JSON Schema Specification + +The `--agent-json` format will output a JSON object containing a `findings` list. Each finding conforms to the following schema: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AgentFinding", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique deterministic identifier for the recommendation type." + }, + "topic": { + "type": "string", + "enum": ["Performance", "Security", "Reliability", "Modeling", "Replication"], + "description": "The category of the finding." + }, + "description": { + "type": "string", + "description": "Human-readable explanation of the issue." + }, + "impact_score": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "description": "Estimated benefit score from 1 (low) to 10 (critical)." + }, + "risk_level": { + "type": "string", + "enum": ["Low", "Medium", "High", "Critical"], + "description": "Risk associated with executing the recommendation." + }, + "risk_description": { + "type": "string", + "description": "Details of potential side effects (e.g. table locks, memory growth, restart required)." + }, + "requires_restart": { + "type": "boolean", + "description": "True if the configuration change requires a database service restart." + }, + "expected_outcome": { + "type": "string", + "description": "The expected performance or security benefit." + }, + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["SQL", "Config", "OS"], + "description": "Type of resolution action." + }, + "statement": { + "type": "string", + "description": "The exact SQL statement, configuration line, or shell command to execute." + }, + "rollback_statement": { + "type": "string", + "description": "The exact command to revert the action to its original state." + } + }, + "required": ["type", "statement", "rollback_statement"] + } + }, + "required": [ + "id", + "topic", + "description", + "impact_score", + "risk_level", + "risk_description", + "requires_restart", + "expected_outcome", + "action" + ] +} +``` + +### Finding Example +```json +{ + "id": "innodb_buffer_pool_size_adjust", + "topic": "Performance", + "description": "InnoDB buffer pool size is under-allocated for the current workload.", + "impact_score": 9, + "risk_level": "Medium", + "risk_description": "Increases memory consumption. Ensure sufficient OS-free RAM to prevent OOM swapping.", + "requires_restart": false, + "expected_outcome": "Reduces disk I/O and increases query cache read hits.", + "action": { + "type": "SQL", + "statement": "SET GLOBAL innodb_buffer_pool_size = 1073741824;", + "rollback_statement": "SET GLOBAL innodb_buffer_pool_size = 134217728;" + } +} +``` + +## ✅ Verification Plan + +### Automated Tests +1. Test generation of `--agent-json` output and validate its conformance to the schema. +2. Validate that each generated recommendation has a corresponding valid `rollback_statement`. +3. Verify that parsing of database metrics properly populates all metadata fields (e.g., `impact_score`, `risk_level`, `requires_restart`). diff --git a/documentation/specifications/roadmap_phase_xvi_mcp_server.md b/documentation/specifications/roadmap_phase_xvi_mcp_server.md new file mode 100644 index 000000000..d141419bf --- /dev/null +++ b/documentation/specifications/roadmap_phase_xvi_mcp_server.md @@ -0,0 +1,71 @@ +# Specification: Roadmap Phase XVII - Dockerized Auditing Daemon & MCP Server Support + +- **Feature Name**: Dockerized Auditing Daemon & MCP Server Support +- **Status**: Draft +- **Created Date**: 2026-06-25 + +## 🧠 Rationale + +AI agents interact with tools and local resources using the **Model Context Protocol (MCP)**. To allow modern LLM coding and platform agents to easily query, inspect, and perform safe self-tuning operations on database servers, MySQLTuner needs to be deployable as an autonomous microservice. + +This phase specifies: +1. **Dockerized Auditing Daemon**: A lightweight Docker image running as a background service that queries the target database at configurable intervals, performs checks, and caches findings. +2. **MCP Server Interface**: Exposing MySQLTuner findings and safe execution statements through standard MCP Tools and Resources. +3. **Caching and Query Layer**: Storing recent runs locally in a cache database/directory to avoid overloading the production database with repeated audits. + +## 🛠️ User Scenarios + +### Scenario 1: Continuous Observability Daemon +A system administrator deploys the MySQLTuner Docker image alongside their database container in a Docker Compose file. The daemon is configured to run an audit every 12 hours. It caches the structured JSON and HTML reports in a shared volume. + +### Scenario 2: LLM-Driven Database Tuning via MCP +An AI coding assistant (like Antigravity or Cursor) is asked to optimize database performance. The agent connects to the MySQLTuner MCP server, invokes the `get_latest_audit` tool to inspect the cached findings, identifies indexing recommendations, and invokes the `apply_recommendation` tool to run the safe SQL optimization. + +## 📋 Technical Architecture + +### 1. Docker Auditing Daemon +The Docker container will run a lightweight daemon script (written in Perl or Python) acting as an orchestrator: +- **Environment Variables**: + - `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD` (or socket mounts). + - `AUDIT_INTERVAL_HOURS`: Run audit every X hours (default: `12`). + - `CACHE_DIR`: Location to persist JSON/HTML reports (default: `/var/cache/mysqltuner`). +- **Interval Auditor**: Sleep/loop execution with SIGTERM/SIGINT signal handling for graceful termination. + +### 2. MCP Server Protocol Support +The MCP server protocol will be implemented over standard I/O (stdio) transport or SSE (Server-Sent Events) HTTP transport. It exposes: + +#### Resources +- `resources/list`: + - `mysqltuner://reports/latest.json`: The latest structured agent JSON. + - `mysqltuner://reports/latest.html`: The latest interactive HTML dashboard. +- `resources/read`: Retrieves the raw content of the cached latest JSON/HTML. + +#### Tools +- `get_latest_audit`: + - Input: None. + - Output: The JSON structure containing current recommendations. +- `run_audit`: + - Input: None. + - Output: Triggers a fresh run of MySQLTuner and returns the latest findings. +- `apply_recommendation`: + - Input: `finding_id` (the ID of the recommendation to execute). + - Behavior: Verifies that the recommendation type is `SQL`, checks that risk is acceptable, and runs the corresponding `statement` against the database. + - Output: Success/Failure status and output logs. +- `rollback_recommendation`: + - Input: `finding_id`. + - Behavior: Runs the corresponding `rollback_statement` to revert changes. + +## ⚠️ Risk & Safety Mitigation + +Tuning operations on production databases pose high risk. The MCP server implements: +1. **Execution Restriction**: Only `SQL` actions of `risk_level` `Low` or `Medium` can be executed automatically. `Critical` risk items require manual override. +2. **Read-Only Mode**: A boot flag `READ_ONLY=true` can be set to disable the write tools (`apply_recommendation`, `rollback_recommendation`), making the MCP server a pure observability tool. +3. **Transaction Logging**: Every applied action and rollback is logged in a state file (`/var/cache/mysqltuner/state.json`) with timestamps. + +## ✅ Verification Plan + +### Manual Verification +1. Build the Docker image: `docker build -t mysqltuner-mcp -f Dockerfile.mcp .`. +2. Run container: `docker run -d -e DB_HOST=localhost -e AUDIT_INTERVAL_HOURS=2 -v /tmp/cache:/var/cache/mysqltuner mysqltuner-mcp`. +3. Verify that `/tmp/cache/latest.json` is generated and updated every 2 hours. +4. Interact with the running container using an MCP client CLI (e.g. `@modelcontextprotocol/inspector`) and verify tools and resources are correctly exposed. diff --git a/mysqltuner.pl b/mysqltuner.pl index 1868d171d..54cb0653c 100755 --- a/mysqltuner.pl +++ b/mysqltuner.pl @@ -8637,6 +8637,7 @@ sub mysql_pfs { schema => $schema, table => $table, index => $index, + sql => "ALTER TABLE $schema.$table DROP INDEX $index", } ); $nbL++; @@ -12816,7 +12817,8 @@ sub format_recommendation_item { my $schema = $item->{schema} // ''; my $table = $item->{table} // ''; my $index = $item->{index} // ''; - return "Unused index: $schema.$table ($index)"; + my $sql = $item->{sql} // ''; + return "Unused index: $schema.$table ($index) (suggested SQL: $sql)"; } elsif ( $item->{type} eq 'redundant_index' ) { my $schema = $item->{schema} // ''; @@ -12949,6 +12951,37 @@ sub _sanitized_result_for_export { return \%copy; } +sub _serialize_to_json { + my ($data) = @_; + if (!defined $data) { + return 'null'; + } + my $ref = ref($data); + if (!$ref) { + if ($data =~ /^-?(?:[0-9]+(?:\.[0-9]+)?)$/) { + return $data; + } + my $escaped = $data; + $escaped =~ s/\\/\\\\/g; + $escaped =~ s/"/\\"/g; + $escaped =~ s/\n/\\n/g; + $escaped =~ s/\r/\\r/g; + $escaped =~ s/\t/\\t/g; + return '"' . $escaped . '"'; + } elsif ($ref eq 'HASH') { + my @pairs; + foreach my $k (sort keys %$data) { + my $v = $data->{$k}; + push @pairs, _serialize_to_json($k) . ':' . _serialize_to_json($v); + } + return '{' . join(',', @pairs) . '}'; + } elsif ($ref eq 'ARRAY') { + my @elems = map { _serialize_to_json($_) } @$data; + return '[' . join(',', @elems) . ']'; + } + return 'null'; +} + sub dump_result { if ( $opt{'json'} && $opt{'yaml'} ) { print STDERR "ERROR: --json and --yaml are mutually exclusive\n"; @@ -12979,7 +13012,36 @@ sub dump_result { ( $details->{'res_logs'} // 10 ) + ( $details->{'res_meta'} // 10 ); + my $report_host = $opt{'host'} // $myvar{'hostname'} // 'localhost'; + my $report_port = $opt{'port'} // $myvar{'port'} // '3306'; + # Render lists + my @sys_recs = ( + @sysrec, + grep { /(swap|swappiness|memory|ram|cpu|process|disk|mountpoint|limit|packet|event|tcp|slot|proc|open files)/i } @generalrec + ); + my %seen_sys; + @sys_recs = grep { !$seen_sys{$_}++ } @sys_recs; + + my @sec_recs = ( + @secrec, + grep { /(cve|security|password|authentication|ssl|encrypt|host|grant|privilege|user|transport)/i } @generalrec + ); + my %seen_sec; + @sec_recs = grep { !$seen_sec{$_}++ } @sec_recs; + + my @conn_recs = + grep { /(connection|connect|thread|max_user_connections|max_connections|aborted)/i } + @generalrec; + + my @perf_recs = + grep { /(slow|query|join|sort|cache|temporary|tmp|lock|started)/i } + @generalrec; + + my @repl_recs = + grep { /(replica|sla[v]e|gtid|replication|binlog|relay)/i } + @generalrec; + my $general_rec_html = join( "\n", map { @@ -13016,7 +13078,7 @@ sub dump_result { "
  • " . escape_html( format_recommendation_item($_) ) . "
  • " - } @secrec + } @sec_recs ) || "
  • No security concerns identified.
  • "; @@ -13026,10 +13088,40 @@ sub dump_result { "
  • " . escape_html( format_recommendation_item($_) ) . "
  • " - } @sysrec + } @sys_recs ) || "
  • No OS or system modifications suggested.
  • "; + my $connections_rec_html = join( + "\n", + map { +"
  • " + . escape_html( format_recommendation_item($_) ) + . "
  • " + } @conn_recs + ) + || "
  • No connection or network recommendations.
  • "; + + my $performance_rec_html = join( + "\n", + map { +"
  • " + . escape_html( format_recommendation_item($_) ) + . "
  • " + } @perf_recs + ) + || "
  • No query or performance recommendations.
  • "; + + my $replication_rec_html = join( + "\n", + map { +"
  • " + . escape_html( format_recommendation_item($_) ) + . "
  • " + } @repl_recs + ) + || "
  • No replication recommendations.
  • "; + # Build raw output HTML my $raw_output_html = join( "\n", map { escape_html($_) } @raw_output_lines ); @@ -13041,13 +13133,307 @@ sub dump_result { my $kpi_repl = $result{'SectionalHealthScore'}{'Replication'} // 100; my $kpi_mode = $result{'SectionalHealthScore'}{'Modeling'} // 100; + # Serialize metrics to JSON for Javascript download + my $json_myvar = _serialize_to_json( \%myvar ); + my $json_mystat = _serialize_to_json( \%mystat ); + my $json_mycalc = _serialize_to_json( \%mycalc ); + my $json_general = _serialize_to_json( \@generalrec ); + my $json_adjvars = _serialize_to_json( \@adjvars ); + my $json_secrec = _serialize_to_json( \@secrec ); + my $json_sysrec = _serialize_to_json( \@sysrec ); + my $json_modeling = _serialize_to_json( \@modeling ); + + # Additional metrics calculations for HTML visual components + my $bp_reads = $mystat{'Innodb_buffer_pool_reads'} // 0; + my $bp_reqs = $mystat{'Innodb_buffer_pool_read_requests'} // 0; + my $bp_hit_pct = 100.0; + if ($bp_reqs > 0) { + $bp_hit_pct = 100.0 * (1.0 - ($bp_reads / $bp_reqs)); + } + $bp_hit_pct = sprintf("%.2f", $bp_hit_pct); + + my $threads_created = $mystat{'Threads_created'} // 0; + my $connections = $mystat{'Connections'} // 0; + my $thread_cache_hit_pct = 100.0; + if ($connections > 0) { + $thread_cache_hit_pct = 100.0 * (1.0 - ($threads_created / $connections)); + } + $thread_cache_hit_pct = sprintf("%.2f", $thread_cache_hit_pct); + + my $tmp_disk = $mystat{'Created_tmp_disk_tables'} // 0; + my $tmp_total = $mystat{'Created_tmp_tables'} // 0; + my $tmp_mem_pct = 100.0; + my $tmp_disk_pct = 0.0; + if ($tmp_total > 0) { + $tmp_disk_pct = 100.0 * ($tmp_disk / $tmp_total); + $tmp_mem_pct = 100.0 - $tmp_disk_pct; + } + $tmp_mem_pct = sprintf("%.1f", $tmp_mem_pct); + $tmp_disk_pct = sprintf("%.1f", $tmp_disk_pct); + + # Schema and Engine Statistics calculations for Storage & Modeling tabs + my $db_count = exists $result{'Databases'}{'List'} ? scalar @{ $result{'Databases'}{'List'} } : 0; + my $total_tables = 0; + my $engines_table_html = ''; + my $engines_status_html = ''; + + if ( exists $result{'Engine'} ) { + my @badges; + my $total_data_size = 0; + my $total_index_size = 0; + my $total_size = 0; + + foreach my $eng ( sort keys %{$result{'Engine'}} ) { + my $info = $result{'Engine'}{$eng}; + my $tbl_count = $info->{'Table Number'} // 0; + my $d_size = $info->{'Data Size'} // 0; + my $i_size = $info->{'Index Size'} // 0; + my $t_size = $info->{'Total Size'} // 0; + + my $status = $info->{'Enabled'} // ''; + my $color_class = ($status eq 'YES' || $status eq 'DEFAULT' || $status eq 'ACTIVE') + ? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20' + : 'text-slate-500 bg-slate-500/5 border-slate-500/10'; + my $prefix = ($status eq 'YES' || $status eq 'DEFAULT' || $status eq 'ACTIVE') ? '+' : '-'; + push @badges, "$prefix$eng"; + + next if $tbl_count == 0; + + $total_tables += $tbl_count; + $total_data_size += $d_size; + $total_index_size += $i_size; + $total_size += $t_size; + + $engines_table_html .= sprintf( + "" . + "%s" . + "%d" . + "%s" . + "%s" . + "%s" . + "", + escape_html($eng), + $tbl_count, + hr_bytes($d_size), + hr_bytes($i_size), + hr_bytes($t_size) + ); + } + if ($engines_table_html) { + $engines_table_html .= sprintf( + "" . + "Total" . + "%d" . + "%s" . + "%s" . + "%s" . + "", + $total_tables, + hr_bytes($total_data_size), + hr_bytes($total_index_size), + hr_bytes($total_size) + ); + } + $engines_status_html = join(' ', @badges); + } + + if (!$engines_table_html) { + $engines_table_html = "No storage engine usage statistics available."; + } + if (!$engines_status_html) { + $engines_status_html = "No storage engine status available."; + } + + my $frg_count = $fragtables // 0; + my $no_pk_count = exists $result{'Tables without PK'} ? scalar @{ $result{'Tables without PK'} } : 0; + my $unused_index_count = grep { ref($_) eq 'HASH' && $_->{type} eq 'unused_index' } @modeling; + my $redundant_index_count = grep { ref($_) eq 'HASH' && $_->{type} eq 'redundant_index' } @modeling; + + # InnoDB Page stats & capacity Calculations + my $bp_pages_total = $mystat{'Innodb_buffer_pool_pages_total'} // 0; + my $bp_pages_free = $mystat{'Innodb_buffer_pool_pages_free'} // 0; + my $bp_pages_used = $bp_pages_total - $bp_pages_free; + my $bp_page_size = $myvar{'innodb_page_size'} // 16384; + my $bp_total_bytes = $bp_pages_total * $bp_page_size; + my $bp_free_bytes = $bp_pages_free * $bp_page_size; + my $bp_used_bytes = $bp_pages_used * $bp_page_size; + + my $innodb_log_info_html = ''; + if ( mysql_version_ge( 8, 0, 30 ) ) { + if ( defined $myvar{'innodb_redo_log_capacity'} ) { + $innodb_log_info_html = "innodb_redo_log_capacity" . hr_bytes($myvar{'innodb_redo_log_capacity'}) . ""; + } else { + $innodb_log_info_html = "innodb_redo_log_capacityN/A"; + } + } else { + my $log_file_size = $myvar{'innodb_log_file_size'} // 0; + my $log_files_in_group = $myvar{'innodb_log_files_in_group'} // 0; + my $total_log_size = $log_file_size * $log_files_in_group; + $innodb_log_info_html = "innodb_log_file_size" . hr_bytes($log_file_size) . "" . + "innodb_log_files_in_group$log_files_in_group" . + "Total Log File Size" . hr_bytes($total_log_size) . " (" . ($mycalc{'innodb_log_size_pct'} // 0) . "% of buffer pool)"; + } + + my $chunk_align_html = ''; + if (defined $myvar{'innodb_buffer_pool_chunk_size'} && defined $myvar{'innodb_buffer_pool_instances'} && $myvar{'innodb_buffer_pool_chunk_size'} > 0 && $myvar{'innodb_buffer_pool_instances'} > 0) { + my $num_chunks = int( ($myvar{'innodb_buffer_pool_size'} // 0) / $myvar{'innodb_buffer_pool_chunk_size'} ); + my $expected_size = int($myvar{'innodb_buffer_pool_chunk_size'}) * int($myvar{'innodb_buffer_pool_instances'}); + my $is_aligned = (int($myvar{'innodb_buffer_pool_size'} // 0) % $expected_size == 0); + my $align_status = $is_aligned ? "Aligned" : "Not Aligned"; + $chunk_align_html = "innodb_buffer_pool_chunk_size" . hr_bytes($myvar{'innodb_buffer_pool_chunk_size'}) . "" . + "InnoDB Buffer Pool Chunks$num_chunks (instances: " . $myvar{'innodb_buffer_pool_instances'} . ")" . + "Chunk Alignment Status$align_status"; + } + + my $innodb_write_rate_html = ''; + my $innodb_os_log_written = $mystat{'Innodb_os_log_written'} || 0; + my $uptime = $mystat{'Uptime'} || 1; + if ($uptime > 3600) { + my $hourly_rate = $innodb_os_log_written / ($uptime / 3600); + $innodb_write_rate_html = "Hourly InnoDB Log Write Rate" . hr_bytes($hourly_rate) . "/hour"; + } else { + $innodb_write_rate_html = "Total InnoDB OS Log Written" . hr_bytes($innodb_os_log_written) . " (uptime < 1h)"; + } + + my $db_table_html = ''; + foreach my $db ( sort @dblist ) { + my $info = $result{'Databases'}{$db}; + next unless defined $info; + my $tbls = $info->{'Tables'} // 0; + my $rows = $info->{'Rows'} // 0; + my $d_size = $info->{'Data Size'} // 0; + my $i_size = $info->{'Index Size'} // 0; + my $t_size = $info->{'Total Size'} // 0; + + my $d_size_str = ($d_size =~ /^\d+$/) ? hr_bytes($d_size) : $d_size; + my $i_size_str = ($i_size =~ /^\d+$/) ? hr_bytes($i_size) : $i_size; + my $t_size_str = ($t_size =~ /^\d+$/) ? hr_bytes($t_size) : $t_size; + + $db_table_html .= sprintf( + "" . + "%s" . + "%d" . + "%d" . + "%s" . + "%s" . + "%s" . + "", + escape_html($db), + $tbls, + $rows, + $d_size_str, + $i_size_str, + $t_size_str + ); + } + if (!$db_table_html) { + $db_table_html = "No user database statistics available."; + } + + my $fragmented_tables_html = ''; + if ( exists $result{'Tables'}{'Fragmented tables'} && scalar @{ $result{'Tables'}{'Fragmented tables'} } > 0 ) { + foreach my $table_line ( @{ $result{'Tables'}{'Fragmented tables'} } ) { + my ( $table_schema, $table_name, $engine, $data_free ) = split /\t/msx, $table_line; + my $free_mb = $data_free / 1024 / 1024; + my $free_str = sprintf("%.2f MB", $free_mb); + my $sql = ($engine eq 'InnoDB') + ? "ALTER TABLE `$table_schema`.`$table_name` FORCE;" + : "OPTIMIZE TABLE `$table_schema`.`$table_name`;"; + $fragmented_tables_html .= sprintf( + "" . + "%s.%s" . + "%s" . + "%s" . + "%s" . + "", + escape_html($table_schema), + escape_html($table_name), + escape_html($engine), + $free_str, + escape_html($sql) + ); + } + } + if (!$fragmented_tables_html) { + $fragmented_tables_html = "No fragmented tables found."; + } + + my $no_pk_tables_html = ''; + if ( exists $result{'Tables without PK'} && scalar @{ $result{'Tables without PK'} } > 0 ) { + foreach my $badtable ( @{ $result{'Tables without PK'} } ) { + my ($schema, $table) = split /,/, $badtable, 2; + if (!defined $table) { + $schema = ''; + $table = $badtable; + } + $no_pk_tables_html .= sprintf( + "" . + "%s.%s" . + "Add explicit primary key for performance, maintenance, and replication stability." . + "", + escape_html($schema), + escape_html($table) + ); + } + } + if (!$no_pk_tables_html) { + $no_pk_tables_html = "All base tables have a primary key or unique index."; + } + + my $redundant_indexes_html = ''; + my $unused_indexes_html = ''; + foreach my $item (@modeling) { + if ( ref($item) eq 'HASH' ) { + if ( ($item->{type} // '') eq 'redundant_index' ) { + my $schema = $item->{schema} // ''; + my $table = $item->{table} // ''; + my $index = $item->{index} // ''; + my $dominant = $item->{dominant_index} // ''; + my $sql = $item->{sql} // ''; + $redundant_indexes_html .= sprintf( + "" . + "%s.%s" . + "%s" . + "redundant of %s" . + "%s" . + "", + escape_html($schema), + escape_html($table), + escape_html($index), + escape_html($dominant), + escape_html($sql) + ); + } + elsif ( ($item->{type} // '') eq 'unused_index' ) { + my $schema = $item->{schema} // ''; + my $table = $item->{table} // ''; + my $index = $item->{index} // ''; + my $sql = $item->{sql} // ''; + $unused_indexes_html .= sprintf( + "" . + "%s.%s" . + "%s" . + "%s" . + "", + escape_html($schema), + escape_html($table), + escape_html($index), + escape_html($sql) + ); + } + } + } + if (!$redundant_indexes_html) { + $redundant_indexes_html = "No redundant indexes detected."; + } + if (!$unused_indexes_html) { + $unused_indexes_html = "No unused indexes detected."; + } + # 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 ); @@ -13057,7 +13443,7 @@ sub dump_result { if ( !@findings ) { return "
    " - . "

    $title🟢 Optimal

    " + . "

    $title🟢 Optimal

    " . "

    No critical issues or recommendations detected in this area.

    " . "
    "; } @@ -13072,13 +13458,13 @@ sub dump_result { . "" . $f->{badge} . "" - . "" + . "" . escape_html( $f->{text} ) . "" . ""; } return "
    " - . "

    $title⚠️ Action Required

    " + . "

    $title⚠️ Action Required

    " . "
      $items_html
    " . "
    "; }; @@ -13173,25 +13559,227 @@ sub dump_result { my $sec_width = int( $sec_score * 100 / 30 ); my $res_width = int( $res_score * 100 / 30 ); - # HTML Content template my $html_content = <<"HTML"; - MySQLTuner Report + MySQLTuner Advanced Report - + +
    @@ -13201,7 +13789,7 @@ sub dump_result {

    MySQLTuner Audit Report

    - $db_ver ($db_comment) + $db_ver ($db_comment) at $report_host:$report_port Tuner $tunerversion

    @@ -13212,303 +13800,931 @@ sub dump_result {
    - -
    - - -
    - - -
    -

    Overall Health Score

    - - -
    - - - - -
    - $score - out of 100 -
    -
    - - -
    - $badge_text -
    -
    - - -
    -

    Category Scores

    - - -
    -
    - Performance - $perf_score / 40 -
    -
    -
    -
    -
    - - -
    -
    - Security - $sec_score / 30 -
    -
    -
    -
    -
    - - -
    -
    - Resilience - $res_score / 30 + + + + + + +
    +
    + +
    +
    +

    Overall Health Score

    +
    + + + + +
    + $score + out of 100 +
    -
    -
    +
    + $badge_text
    -
    - - $historical_deltas_html -
    - - -
    - - -
    -
    -

    Sectional Indicators & KPIs Dashboard

    - Phase 13 Unified View -
    - -
    - -
    - General Stats - $kpi_gen% -
    -
    + +
    +

    Category Scores

    +
    +
    + Performance + $perf_score / 40
    -
    - -
    - Storage & Perf - $kpi_stor% -
    -
    +
    +
    - -
    - Security - $kpi_sec% -
    -
    +
    +
    + Security + $sec_score / 30
    -
    - -
    - Replication & HA - $kpi_repl% -
    -
    +
    +
    - -
    - SQL Modeling - $kpi_mode% -
    -
    +
    +
    + Resilience + $res_score / 30 +
    +
    +
    + $historical_deltas_html +
    - -
    - -
    -

    Resource Saturation Heatmap

    - -
    -
    - CPU Load Saturation - $sat_cpu% -
    + +
    +
    +
    +

    Sectional Indicators & KPIs Dashboard

    + Phase 13 Unified View +
    +
    +
    + General Stats + $kpi_gen%
    -
    +
    - -
    -
    - Memory Saturation - $sat_mem% -
    +
    + Storage & Perf + $kpi_stor%
    -
    +
    - -
    -
    - Connections Saturation - $sat_conn% -
    +
    + Security + $kpi_sec%
    -
    +
    - -
    -
    - Disk I/O Pressure - $sat_io% +
    + Replication & HA + $kpi_repl% +
    +
    +
    +
    + SQL Modeling + $kpi_mode%
    -
    +
    - -
    -
    -

    Throughput Efficiency Index

    -
    -
    - Logical Work - $tei_qps QPS +
    + +
    +

    Resource Saturation Heatmap

    +
    +
    + CPU Load Saturation + $sat_cpu%
    -
    - Buffer Logical Reads - $tei_reads/s +
    +
    +
    +
    +
    +
    + Memory Saturation + $sat_mem% +
    +
    +
    +
    +
    +
    +
    + Connections Saturation + $sat_conn% +
    +
    +
    +
    +
    +
    +
    + Disk I/O Pressure + $sat_io% +
    +
    +
    -
    -
    -
    - Efficiency Ratio - Queries completed per logical page read + + +
    +
    +

    Throughput Efficiency Index

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

    Prioritized Executive Summary Findings

    -
    - $gen_findings_html - $store_findings_html - $sec_findings_html - $repl_findings_html - $model_findings_html + +
    +

    Prioritized Executive Summary Findings

    +
    + $gen_findings_html + $store_findings_html + $sec_findings_html + $repl_findings_html + $model_findings_html +
    +
    +
    +
    + + +
    +
    + +
    + + +

    Full Console Output Trace

    +
    + Show Logs + +
    +
    +
    $raw_output_html
    -
    + +
    +
    - -
    -
    - -

    General Recommendations

    + + + + + + + +
    +
    - -
    -
    + +
    +

    InnoDB Memory Configuration

    +
    +
    + innodb_buffer_pool_size + @{[$myvar{innodb_buffer_pool_size} ? sprintf("%.2f MB", $myvar{innodb_buffer_pool_size} / 1024 / 1024) : 'N/A']} +
    +
    + innodb_log_buffer_size + @{[$myvar{innodb_log_buffer_size} ? sprintf("%.2f MB", $myvar{innodb_log_buffer_size} / 1024 / 1024) : 'N/A']} +
    +
    + innodb_file_per_table + @{[$myvar{innodb_file_per_table} // 'N/A']} +
    +
    +
    +
    + + +
    +

    Enabled Storage Engines Status

    +
    + $engines_status_html +
    +
    + + +
    +

    Storage Engine Data Distribution

    +
    + + + + + + + + + + + + $engines_table_html + +
    Storage EngineTables CountData SizeIndex SizeTotal Size
    +
    +
    + + +
    +

    InnoDB Engine Detailed Diagnostics

    +
    + + + + + + + + + + + + + $chunk_align_html + $innodb_log_info_html + + + + + + + + + + + + + + + + + + + + + $innodb_write_rate_html + +
    Diagnostic ParameterValue / Status
    InnoDB Buffer Pool Instances@{[$myvar{innodb_buffer_pool_instances} // 'N/A']}
    InnoDB Buffer Pool Total Pages@{[$mystat{Innodb_buffer_pool_pages_total} ? sprintf("%d pages (%s)", $mystat{Innodb_buffer_pool_pages_total}, hr_bytes($bp_total_bytes)) : 'N/A']}
    InnoDB Buffer Pool Free Pages@{[$mystat{Innodb_buffer_pool_pages_free} ? sprintf("%d pages (%s)", $mystat{Innodb_buffer_pool_pages_free}, hr_bytes($bp_free_bytes)) : 'N/A']}
    InnoDB Buffer Pool Used Pages@{[$mystat{Innodb_buffer_pool_pages_total} ? sprintf("%d pages (%s)", $bp_pages_used, hr_bytes($bp_used_bytes)) : 'N/A']}
    InnoDB Thread Concurrency@{[$myvar{innodb_thread_concurrency} // 'N/A']}
    InnoDB Read Efficiency$bp_hit_pct% (hit rate)
    +
    +
    + + +
    +
    +
    -

    Variables to Adjust

    +

    Variables to Adjust (Storage Recommendations)

    -
    -
      - $adjvars_html -
    +
    + +
    -
    +
    +
    +
      + $adjvars_html +
    +
    +
    +
    - -
    -
    - -

    Database Modeling Findings

    + + +
    +
      + $performance_rec_html +
    +
    +
    + - -
    -
    - -

    Security Findings

    + +
    + + - -
    -
    - -

    System & OS Recommendations

    +
    +
    +
    + +

    Security Findings & Hardening Advice

    -
    -
      - $sysrec_html -
    +
    + +
    -
    +
    +
    +
      + $secrec_html +
    +
    +
    + + + + - -
    -
    - + + -
    -
    $raw_output_html
    +
    + + +
    +
    +
    +
      + $replication_rec_html +
    +
    +
    + + + + + + +