From 0d9f455294f6d0350087f6a806a0fe9b0b7e1f9b Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Thu, 14 May 2026 16:30:07 -0700 Subject: [PATCH 1/2] v2 db.php drop-in: early-query coverage + zero-overhead observation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds HCQG_DB (extends wpdb) for two capabilities the mu-plugin alone cannot provide: SET SESSION MAX_EXECUTION_TIME at connection time (covering pre-init queries like wp_load_alloptions), and conditional backtracing that replaces SAVEQUERIES — dropping 100% observation overhead from ~10% CPU to near zero. Mu-plugin coordination: dropin_active() gates three codepaths so the mu-plugin reads from hcqg_slow_queries when the drop-in is present and falls back to SAVEQUERIES when it isn't. Safety: dependency guard on the mu-plugin returns early with a log warning if any companion class file is missing, preventing fatal errors during partial uploads. README updated with v2 installation instructions including upload order, db.php deployment, and Query Monitor conflict notes. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 35 +++++- db.php | 230 ++++++++++++++++++++++++++++++++++++++ hypercart-query-guard.php | 80 ++++++++++--- tests/DbDropinTest.php | 161 ++++++++++++++++++++++++++ tests/bootstrap.php | 48 ++++++++ 5 files changed, 531 insertions(+), 23 deletions(-) create mode 100644 db.php create mode 100644 tests/DbDropinTest.php diff --git a/README.md b/README.md index f487f90..1f0127a 100644 --- a/README.md +++ b/README.md @@ -21,16 +21,39 @@ Built for high-volume WooCommerce stores on managed hosts (WP Engine, Pressable, ## Installation -Copy all plugin PHP files into `wp-content/mu-plugins/`: +### MU-plugin (required) + +Copy **all four** PHP files into `wp-content/mu-plugins/`. All four must be present — if any companion file is missing, the plugin logs a warning and disables itself rather than crashing the site. ```text -wp-content/mu-plugins/hypercart-query-guard.php -wp-content/mu-plugins/class-hcqg-load-monitor.php -wp-content/mu-plugins/class-hcqg-priority-registry.php -wp-content/mu-plugins/class-hcqg-mutex-guard.php +wp-content/mu-plugins/class-hcqg-load-monitor.php ← copy first +wp-content/mu-plugins/class-hcqg-priority-registry.php ← copy first +wp-content/mu-plugins/class-hcqg-mutex-guard.php ← copy first +wp-content/mu-plugins/hypercart-query-guard.php ← copy last ``` -No activation step. MU-plugins load automatically. +**Upload order matters on live sites.** WordPress loads mu-plugins on every request with no activation step. Copy the three `class-hcqg-*.php` files first, then `hypercart-query-guard.php` last. If a request arrives after the main file is uploaded but before the companions, and the companions are missing, the plugin will safely disable itself for that request. + +### db.php drop-in (optional, recommended) + +The v2 drop-in extends `wpdb` to provide two capabilities the mu-plugin alone cannot: + +1. **Early-query coverage** — applies `SET SESSION MAX_EXECUTION_TIME` at connection time, before `wp_load_alloptions()` and other pre-`init` queries. +2. **Zero-overhead observation** — replaces WordPress core's `SAVEQUERIES` (which calls `debug_backtrace()` on every query unconditionally) with conditional backtracing that only captures slow queries. This drops the CPU cost of 100% observation from ~10% to near zero. + +Copy `db.php` to `wp-content/`: + +```text +wp-content/db.php +``` + +The drop-in is independent of the mu-plugin and has no file dependencies. It can be installed before, after, or without the mu-plugin: + +- **db.php present, mu-plugin present** — full v2 behavior. The mu-plugin detects the drop-in and uses it for logging and tiered limit updates. +- **db.php present, mu-plugin absent** — drop-in runs standalone. Conditional backtracing and SET SESSION protection are active, but there is no structured logging, no tiered limits, and no admin-search fallback notice. +- **db.php absent, mu-plugin present** — v1 behavior. The mu-plugin hooks `init` at priority 1 and uses `SAVEQUERIES` for observation. + +**Note:** WordPress only supports one `wp-content/db.php` at a time. If another plugin (e.g., Query Monitor) has installed a db.php, you must remove it first. Query Monitor's db.php forces `SAVEQUERIES = true` on every request — even when QM is deactivated — adding ~10% CPU overhead with no benefit if QM isn't actively in use. ## Configuration diff --git a/db.php b/db.php new file mode 100644 index 0000000..3d11f3b --- /dev/null +++ b/db.php @@ -0,0 +1,230 @@ +queries: [ $sql, $elapsed_s, $caller, $start_µs, $data ]. + * + * @var array + */ + public $hcqg_slow_queries = array(); + + /** @var bool Whether conditional backtracing is active for this request. */ + private $hcqg_active = false; + + /** @var bool Whether enforce-mode SET SESSION should be applied. */ + private $hcqg_enforce = false; + + /** @var bool Whether the first-query SET SESSION has been applied. */ + private $hcqg_session_applied = false; + + /** @var mixed Connection identity for reconnect / rotation detection. */ + private $hcqg_last_dbh = null; + + /** @var float Warn threshold in seconds. */ + private $hcqg_warn_threshold_s = 5.0; + + /** @var int Current MAX_EXECUTION_TIME limit in ms. */ + private $hcqg_current_limit_ms = 30000; + + /** + * @param string $dbuser + * @param string $dbpassword + * @param string $dbname + * @param string $dbhost + */ + public function __construct( $dbuser, $dbpassword, $dbname, $dbhost ) { + $this->hcqg_resolve_config(); + parent::__construct( $dbuser, $dbpassword, $dbname, $dbhost ); + $this->hcqg_version_check(); + } + + /** + * Read mode and thresholds from wp-config.php constants. + */ + private function hcqg_resolve_config() { + $mode = 'observe'; + if ( defined( 'HYPERCART_QUERY_GUARD_MODE' ) ) { + $raw = HYPERCART_QUERY_GUARD_MODE; + if ( in_array( $raw, array( 'off', 'observe', 'enforce' ), true ) ) { + $mode = $raw; + } + } + + $this->hcqg_active = ( 'off' !== $mode ); + $this->hcqg_enforce = ( 'enforce' === $mode ); + + if ( defined( 'HYPERCART_QUERY_GUARD_WARN_THRESHOLD_MS' ) ) { + $this->hcqg_warn_threshold_s = max( 0, (int) HYPERCART_QUERY_GUARD_WARN_THRESHOLD_MS ) / 1000; + } + + if ( defined( 'HYPERCART_QUERY_GUARD_DEFAULT_LIMIT_MS' ) ) { + $this->hcqg_current_limit_ms = max( 0, (int) HYPERCART_QUERY_GUARD_DEFAULT_LIMIT_MS ); + } + } + + /** + * Log a notice if WordPress version is outside the tested range. + */ + private function hcqg_version_check() { + global $wp_version; + + if ( ! isset( $wp_version ) ) { + return; + } + + if ( version_compare( $wp_version, self::WP_VERSION_FLOOR, '<' ) || + version_compare( $wp_version, self::WP_VERSION_CEILING, '>' ) ) { + error_log( sprintf( + '[hypercart_query_guard][warn] db.php drop-in tested on WP %s–%s; running %s. ' + . 'Verify wpdb::query() override compatibility.', + self::WP_VERSION_FLOOR, + self::WP_VERSION_CEILING, + $wp_version + ) ); + } + } + + /** + * Override query() for conditional backtracing and first-query SET SESSION. + * + * @param string $query SQL query. + * @return int|bool + */ + public function query( $query ) { + // Enforce: apply or re-apply SET SESSION on first query / reconnect / rotation. + if ( $this->hcqg_enforce && + ( ! $this->hcqg_session_applied || $this->hcqg_last_dbh !== $this->dbh ) ) { + $this->hcqg_apply_session(); + } + + // Off mode: no timing, no backtracing. + if ( ! $this->hcqg_active ) { + return parent::query( $query ); + } + + $start = microtime( true ); + $result = parent::query( $query ); + $elapsed = microtime( true ) - $start; + + // Conditional backtrace: ~0.1ms cost, only for slow queries. + if ( $elapsed >= $this->hcqg_warn_threshold_s ) { + $this->hcqg_slow_queries[] = array( + $query, + $elapsed, + $this->get_caller(), + $start, + array(), + ); + } + + return $result; + } + + /** + * Apply SET SESSION MAX_EXECUTION_TIME via raw mysqli. + * Bypasses $this->query() to avoid recursion and timing noise. + */ + private function hcqg_apply_session() { + if ( empty( $this->dbh ) || ! ( $this->dbh instanceof mysqli ) ) { + return; + } + + $limit_ms = $this->hcqg_current_limit_ms; + + if ( defined( 'WP_CLI' ) && WP_CLI ) { + $limit_ms = 0; + } + + if ( $limit_ms > 0 ) { + @mysqli_query( $this->dbh, sprintf( 'SET SESSION MAX_EXECUTION_TIME = %d', $limit_ms ) ); + } + + $this->hcqg_session_applied = true; + $this->hcqg_last_dbh = $this->dbh; + } + + // -- Public API for the mu-plugin ------------------------------------------ + + /** + * Update the limit after context detection and re-apply immediately. + * Called by the mu-plugin on init once the request context (admin, REST, + * checkout, etc.) is known and the correct tiered limit is resolved. + * + * Also caches the limit so reconnect/rotation re-applies the correct + * per-context value instead of the pre-init default. + * + * @param int $limit_ms Milliseconds. 0 = unlimited. + */ + public function hcqg_update_limit( $limit_ms ) { + $limit_ms = max( 0, (int) $limit_ms ); + $this->hcqg_current_limit_ms = $limit_ms; + + if ( ! $this->hcqg_enforce || empty( $this->dbh ) || ! ( $this->dbh instanceof mysqli ) ) { + return; + } + + if ( $limit_ms > 0 ) { + @mysqli_query( $this->dbh, sprintf( 'SET SESSION MAX_EXECUTION_TIME = %d', $limit_ms ) ); + } + + $this->hcqg_last_dbh = $this->dbh; + } + + /** + * Whether the drop-in's instrumentation is active for this request. + * + * @return bool + */ + public function hcqg_is_active() { + return $this->hcqg_active; + } +} + +// --------------------------------------------------------------------------- +// Instantiate the custom wpdb. WordPress's require_wp_db() checks +// `isset( $wpdb )` and skips `new wpdb(...)` if we set it here. +// --------------------------------------------------------------------------- +$wpdb = new HCQG_DB( // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + defined( 'DB_USER' ) ? DB_USER : '', + defined( 'DB_PASSWORD' ) ? DB_PASSWORD : '', + defined( 'DB_NAME' ) ? DB_NAME : '', + defined( 'DB_HOST' ) ? DB_HOST : '' +); diff --git a/hypercart-query-guard.php b/hypercart-query-guard.php index ad11beb..8672b29 100644 --- a/hypercart-query-guard.php +++ b/hypercart-query-guard.php @@ -35,6 +35,21 @@ exit; } +$hcqg_required_files = array( + __DIR__ . '/class-hcqg-load-monitor.php', + __DIR__ . '/class-hcqg-priority-registry.php', + __DIR__ . '/class-hcqg-mutex-guard.php', +); +foreach ( $hcqg_required_files as $hcqg_file ) { + if ( ! file_exists( $hcqg_file ) ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + error_log( '[hypercart_query_guard][error] Missing required file: ' . basename( $hcqg_file ) . ' — plugin disabled.' ); + } + return; + } +} +unset( $hcqg_required_files, $hcqg_file ); + require_once __DIR__ . '/class-hcqg-load-monitor.php'; require_once __DIR__ . '/class-hcqg-priority-registry.php'; require_once __DIR__ . '/class-hcqg-mutex-guard.php'; @@ -159,6 +174,16 @@ final class Hypercart_Query_Guard { */ private static $throttle_runtime = array(); + /** + * Whether the v2 db.php drop-in is active for this request. + * + * @return bool + */ + private static function dropin_active() { + global $wpdb; + return ( isset( $wpdb ) && method_exists( $wpdb, 'hcqg_is_active' ) && $wpdb->hcqg_is_active() ); + } + /** * Bootstrap. */ @@ -182,27 +207,26 @@ public static function init() { } } - // Apply the SET SESSION early. Priority 1 on 'init' is as early as - // reliably available without a db.php drop-in. if ( self::MODE_ENFORCE === $mode ) { + // v2 drop-in applies SET SESSION on first query (pre-init). + // We still hook init to refine the limit with the correct + // per-context tier (admin 45s, checkout 60s, etc.). add_action( 'init', array( __CLASS__, 'apply_session_timeout' ), 1 ); add_action( 'rest_api_init', array( __CLASS__, 'apply_session_timeout' ), 1 ); add_action( 'admin_init', array( __CLASS__, 'apply_session_timeout' ), 1 ); - // Catch the kill, log it, and surface a recovery notice in admin. - // The 'query' filter fires before each subsequent query, so we - // can capture the previous query's error before wpdb::flush() - // clears it. Without this, multiple kills in one request would - // collapse into a single log line (the last one). Shutdown is - // the fallback for the final query of the request. add_filter( 'query', array( __CLASS__, 'capture_pending_kill_filter' ), 1 ); add_action( 'shutdown', array( __CLASS__, 'detect_and_log_kill' ), 0 ); add_action( 'admin_notices', array( __CLASS__, 'render_admin_search_notice' ) ); } - // Observe mode (and enforce mode, additively) logs slow queries via - // SAVEQUERIES. Sampled to keep memory overhead bounded. - if ( self::should_observe_queries( $mode ) ) { + // v2 drop-in: conditional backtracing at 100% replaces SAVEQUERIES. + // v1 fallback: SAVEQUERIES sampled at OBSERVE_SAMPLE_PCT. + if ( self::dropin_active() ) { + if ( self::MODE_OFF !== $mode ) { + add_action( 'shutdown', array( __CLASS__, 'log_slow_queries' ), 1 ); + } + } elseif ( self::should_observe_queries( $mode ) ) { if ( ! defined( 'SAVEQUERIES' ) ) { define( 'SAVEQUERIES', true ); } @@ -888,16 +912,27 @@ public static function apply_session_timeout() { return; // Unlimited contexts (WP-CLI, Action Scheduler). } + // v2 drop-in: update the cached limit and re-apply via raw mysqli. + // This also caches the per-context limit so reconnect/rotation + // re-applies the correct tier instead of the pre-init default. + if ( self::dropin_active() ) { + static $dropin_last_limit = null; + if ( $dropin_last_limit === $limit_ms ) { + return; + } + $wpdb->hcqg_update_limit( $limit_ms ); + $dropin_last_limit = $limit_ms; + return; + } + + // v1 fallback. static $last_dbh = null; static $last_limit = null; - // Skip the round-trip if both connection identity and limit are unchanged. if ( $last_dbh === $wpdb->dbh && $last_limit === $limit_ms ) { return; } - // Suppress wpdb's own error reporting for the SET itself; if MySQL - // rejects it (very old version), we don't want to break the request. $prev_suppress = $wpdb->suppress_errors( true ); $wpdb->query( $wpdb->prepare( 'SET SESSION MAX_EXECUTION_TIME = %d', $limit_ms ) ); $wpdb->suppress_errors( $prev_suppress ); @@ -1023,10 +1058,21 @@ public static function render_admin_search_notice() { public static function log_slow_queries() { global $wpdb; - if ( empty( $wpdb ) || ! defined( 'SAVEQUERIES' ) || ! SAVEQUERIES ) { + if ( empty( $wpdb ) ) { return; } - if ( empty( $wpdb->queries ) || ! is_array( $wpdb->queries ) ) { + + // v2 drop-in populates hcqg_slow_queries (already filtered by threshold). + // v1 reads from $wpdb->queries (populated by SAVEQUERIES). + if ( self::dropin_active() ) { + $queries = $wpdb->hcqg_slow_queries; + } elseif ( defined( 'SAVEQUERIES' ) && SAVEQUERIES && ! empty( $wpdb->queries ) ) { + $queries = $wpdb->queries; + } else { + return; + } + + if ( empty( $queries ) || ! is_array( $queries ) ) { return; } @@ -1034,7 +1080,7 @@ public static function log_slow_queries() { $context = self::detect_context(); $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; - foreach ( $wpdb->queries as $row ) { + foreach ( $queries as $row ) { // $row = [ $query, $duration_seconds, $callstack, $start_microtime, ... ] if ( ! isset( $row[1] ) || $row[1] < $threshold_s ) { continue; diff --git a/tests/DbDropinTest.php b/tests/DbDropinTest.php new file mode 100644 index 0000000..d55dee9 --- /dev/null +++ b/tests/DbDropinTest.php @@ -0,0 +1,161 @@ +setAccessible( true ); + self::$dropin_active = $ref; + } + + protected function setUp(): void { + WP_Stub_State::reset(); + $GLOBALS['wpdb']->reset(); + Hypercart_Logger::reset(); + } + + // -- dropin_active() detection ------------------------------------------- + + public function test_dropin_active_false_by_default(): void { + $this->assertFalse( self::$dropin_active->invoke( null ) ); + } + + public function test_dropin_active_true_when_hcqg_methods_present_and_active(): void { + $GLOBALS['wpdb']->hcqg_set_active( true ); + $this->assertTrue( self::$dropin_active->invoke( null ) ); + } + + public function test_dropin_active_false_when_methods_present_but_inactive(): void { + $GLOBALS['wpdb']->hcqg_set_active( false ); + $this->assertFalse( self::$dropin_active->invoke( null ) ); + } + + // -- log_slow_queries() v2 codepath -------------------------------------- + + public function test_log_slow_queries_reads_hcqg_slow_queries_when_dropin_active(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( true ); + + $wpdb->hcqg_slow_queries = array( + array( + 'SELECT * FROM wp_posts WHERE ID = 1', + 6.5, // 6500ms > 5000ms threshold + 'test_caller → some_function', + microtime( true ) - 6.5, + array(), + ), + ); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertCount( 1, Hypercart_Logger::$calls ); + $this->assertSame( 'warn', Hypercart_Logger::$calls[0]['level'] ); + $this->assertSame( 'slow_query', Hypercart_Logger::$calls[0]['payload']['event'] ); + $this->assertSame( 6500, Hypercart_Logger::$calls[0]['payload']['duration_ms'] ); + } + + public function test_log_slow_queries_skips_below_threshold_from_dropin(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( true ); + + $wpdb->hcqg_slow_queries = array( + array( 'SELECT 1', 3.0, 'caller', microtime( true ), array() ), + ); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertEmpty( Hypercart_Logger::$calls ); + } + + public function test_log_slow_queries_returns_early_when_dropin_buffer_empty(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( true ); + $wpdb->hcqg_slow_queries = array(); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertEmpty( Hypercart_Logger::$calls ); + } + + public function test_log_slow_queries_ignores_hcqg_buffer_without_dropin(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( false ); + $wpdb->hcqg_slow_queries = array( + array( 'SELECT should_be_ignored', 7.0, 'caller', microtime( true ), array() ), + ); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertEmpty( Hypercart_Logger::$calls ); + } + + public function test_log_slow_queries_logs_multiple_slow_queries(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( true ); + + $wpdb->hcqg_slow_queries = array( + array( 'SELECT * FROM wp_posts', 5.5, 'caller_a', microtime( true ), array() ), + array( 'SELECT * FROM wp_options', 8.2, 'caller_b', microtime( true ), array() ), + ); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertCount( 2, Hypercart_Logger::$calls ); + $this->assertSame( 5500, Hypercart_Logger::$calls[0]['payload']['duration_ms'] ); + $this->assertSame( 8200, Hypercart_Logger::$calls[1]['payload']['duration_ms'] ); + } + + public function test_log_slow_queries_truncates_long_sql(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( true ); + + $long_query = str_repeat( 'X', 1000 ); + $wpdb->hcqg_slow_queries = array( + array( $long_query, 6.0, 'caller', microtime( true ), array() ), + ); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertCount( 1, Hypercart_Logger::$calls ); + $logged_query = Hypercart_Logger::$calls[0]['payload']['query']; + $this->assertLessThanOrEqual( 520, strlen( $logged_query ) ); + $this->assertStringContainsString( 'truncated', $logged_query ); + } + + // -- apply_session_timeout() v2 codepath --------------------------------- + + public function test_apply_session_timeout_uses_hcqg_update_limit_when_dropin_active(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( true ); + $wpdb->dbh = new stdClass(); + + Hypercart_Query_Guard::apply_session_timeout(); + + $this->assertSame( 30000, $wpdb->hcqg_last_limit ); + } + + public function test_apply_session_timeout_v1_fallback_without_dropin(): void { + $wpdb = $GLOBALS['wpdb']; + $wpdb->hcqg_set_active( false ); + $wpdb->dbh = new stdClass(); + + Hypercart_Query_Guard::apply_session_timeout(); + + $this->assertNotEmpty( $wpdb->queries ); + $this->assertStringContainsString( 'MAX_EXECUTION_TIME', $wpdb->queries[0] ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0f0f87e..eab0810 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -59,12 +59,46 @@ final class WP_Stub_DB { /** @var array FIFO queue of get_var() returns. */ public $next_get_var = array(); + // v2 drop-in simulation fields. + /** @var array Simulates HCQG_DB::$hcqg_slow_queries. */ + public $hcqg_slow_queries = array(); + /** @var bool Whether to simulate an active drop-in. */ + private $hcqg_active = false; + /** @var int|null Last limit passed to hcqg_update_limit(). */ + public $hcqg_last_limit = null; + /** @var mixed Simulated connection handle. */ + public $dbh = null; + /** @var string */ + public $last_error = ''; + /** @var int */ + public $num_queries = 0; + + public function hcqg_is_active(): bool { return $this->hcqg_active; } + public function hcqg_set_active( bool $active ): void { $this->hcqg_active = $active; } + public function hcqg_update_limit( int $limit_ms ): void { $this->hcqg_last_limit = $limit_ms; } + + /** @var bool */ + private $suppress = false; + + public function suppress_errors( $suppress = true ) { + $prev = $this->suppress; + $this->suppress = (bool) $suppress; + return $prev; + } + public function reset(): void { $this->rows_affected = 0; $this->queries = array(); $this->prepared = array(); $this->next_query_rows_affected = array(); $this->next_get_var = array(); + $this->hcqg_slow_queries = array(); + $this->hcqg_active = false; + $this->hcqg_last_limit = null; + $this->dbh = null; + $this->last_error = ''; + $this->num_queries = 0; + $this->suppress = false; } /** @@ -269,6 +303,20 @@ function esc_html__( $text, $domain = 'default' ) { } } +/** + * Stub logger that captures calls so tests can inspect log output + * without relying on error_log() interception. + */ +class Hypercart_Logger { + /** @var array */ + public static $calls = array(); + + public static function reset(): void { self::$calls = array(); } + public static function error( $channel, $payload ) { self::$calls[] = array( 'level' => 'error', 'channel' => $channel, 'payload' => $payload ); } + public static function info( $channel, $payload ) { self::$calls[] = array( 'level' => 'info', 'channel' => $channel, 'payload' => $payload ); } + public static function warn( $channel, $payload ) { self::$calls[] = array( 'level' => 'warn', 'channel' => $channel, 'payload' => $payload ); } +} + $GLOBALS['wpdb'] = new WP_Stub_DB(); require_once __DIR__ . '/../class-hcqg-load-monitor.php'; From 63d19cc5ea136fbebd2d3f7c9c6ff691a3ec412a Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Thu, 14 May 2026 17:11:38 -0700 Subject: [PATCH 2/2] Add diagnostic test query and README improvements Adds ?hcqg_test=1 URL param (admin-only) that fires a 6-second SLEEP query to validate the slow-query logging pipeline end-to-end. README: quick-test section at top, dependency guard docs, single-file distribution note for future. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 10 ++++++++++ hypercart-query-guard.php | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/README.md b/README.md index 1f0127a..e1b2589 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # Hypercart Query Guard +## Quick test + +Append `?hcqg_test=1` to any URL while logged in as admin. This fires a 6-second `SELECT SLEEP(6)` that triggers the slow-query logging pipeline. Check `debug.log` (or your Hypercart_Logger output) for a `slow_query` event to confirm end-to-end operation. + +--- + A PHP-side circuit breaker for WordPress that enforces MySQL `MAX_EXECUTION_TIME` on read queries to prevent runaway `SELECT`s from saturating a managed-hosting pod. Built for high-volume WooCommerce stores on managed hosts (WP Engine, Pressable, Kinsta) where you don't have access to `pt-kill` or shell-level MySQL controls. Solves the failure mode where a single bad admin search, a stuck background sync, or an unindexed plugin query takes down the entire site by exhausting CPU and PHP-FPM workers. @@ -244,6 +250,10 @@ Event types: - **Each defer creates a new `wp_actionscheduler_actions` row.** The original is canceled, the deferred clone is pending in the future. Under sustained critical load this can grow the canceled-row population materially before AS pruning catches up. Monitor `wp_actionscheduler_actions` row counts during enforce-mode rollouts. - **Benchmark the queue-depth probe on large stores before enforce.** The due-queue-depth probe is cheap on a healthy `actionscheduler_actions` index, but it is still a real SQL query. Use `test_observe` first and inspect the logged probe timings before enabling `enforce`. +## Future: single-file distribution + +The mu-plugin currently ships as four files. This keeps subsystems cleanly separated during development but creates deployment friction — partial uploads can fatal a site if the main file arrives before its companions (the dependency guard prevents this now, but the plugin silently disables itself until all files are present). A future improvement is a build step that concatenates all four files into a single `hypercart-query-guard.php` for distribution, eliminating upload-order concerns entirely. + ## Architecture For an engineering-level overview of how the plugin is put together — subsystem boundaries, the throttle decision pipeline, cross-request state model, mode matrix, public filter surface, and the testing approach — see [ARCHITECTURE.md](ARCHITECTURE.md). diff --git a/hypercart-query-guard.php b/hypercart-query-guard.php index 8672b29..7a9be30 100644 --- a/hypercart-query-guard.php +++ b/hypercart-query-guard.php @@ -232,6 +232,20 @@ public static function init() { } add_action( 'shutdown', array( __CLASS__, 'log_slow_queries' ), 1 ); } + + add_action( 'init', array( __CLASS__, 'maybe_run_diagnostic_query' ), 99 ); + } + + /** + * Fire a SLEEP() query when ?hcqg_test=1 is present. Admin-only. + * Triggers the slow-query logging pipeline for end-to-end validation. + */ + public static function maybe_run_diagnostic_query() { + if ( ! isset( $_GET['hcqg_test'] ) || ! current_user_can( 'manage_options' ) ) { + return; + } + global $wpdb; + $wpdb->query( 'SELECT SLEEP(6)' ); } /**