diff --git a/CHANGELOG.md b/CHANGELOG.md index 0baf0d9..2c23d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to Hypercart Query Guard are documented here. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Every merged build carries a version number. +## [1.2.0] — 2026-06-17 + +### Added + +- **Order-notes caller probe (opt-in diagnostic).** A new `pre_get_comments` hook identifies the originating caller of the "order-notes mega-query" — the unindexed `SELECT wp_comments.* … WHERE comment_ID IN ( … ~11,000 IDs … )` produced when WooCommerce's `wc_get_order_notes()` is invoked without an `order_id`/`order__in`, loading every order note on the site in one cache-prime (scanning ~9M rows, growing without bound). The existing `slow_query`/`query_killed` classifier flags *that* such a query ran, but the slow-query caller field stops at `wc-order-functions.php`; this probe fires before the query executes and records a full `wp_debug_backtrace_summary()` so the upstream caller (plugin/column/export omitting the order scope) can be named. Emits a `warn`-level `order_notes_unscoped` event with `context`, `action`, `uri`, `referer`, `limit`, and `caller`. + +- **`HYPERCART_QUERY_GUARD_LOG_ORDER_NOTES` constant.** Gates the probe; off unless defined truthy in `wp-config.php`. Also overridable via the new `hypercart_query_guard_log_order_notes` filter. When disabled, the hook is never registered (zero overhead). Volume is bounded three ways: non-order-note comment queries return immediately, *scoped* per-order loads are ignored (only the unscoped whole-table load is logged), and a per-request cap (`ORDER_NOTES_LOG_MAX_PER_REQUEST`, default 5) guards against a looping caller. + ## [1.1.0] — 2026-05-30 ### Changed diff --git a/hypercart-query-guard.php b/hypercart-query-guard.php index 3594b4e..8c3a6cc 100644 --- a/hypercart-query-guard.php +++ b/hypercart-query-guard.php @@ -3,7 +3,7 @@ * Plugin Name: Hypercart Query Guard * Plugin URI: https://hypercart.io * Description: PHP-side circuit breaker that enforces MySQL MAX_EXECUTION_TIME on read queries to prevent runaway SELECTs from saturating the pod. Tiered limits per request context, observe-mode for safe rollout, automatic re-application on connection rotation, and admin-search timeout fallback. - * Version: 1.1.0 + * Version: 1.2.0 * Author: Hypercart / Neochrome * License: GPL-2.0-or-later * License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -19,6 +19,11 @@ * Modes: 'off' | 'test_observe' | 'observe' | 'enforce' * (default: 'off') * + * Order-notes probe: define( 'HYPERCART_QUERY_GUARD_LOG_ORDER_NOTES', true ); + * Diagnostic. Logs a backtrace for unscoped + * wc_get_order_notes() loads (the order-notes mega-query) + * to identify the calling code. Off by default. + * * v2 drop-in: Optional wp-content/db.php applies SET SESSION at * connection time and conditionally backtraces slow * queries. Without the drop-in, this MU-plugin falls @@ -74,6 +79,21 @@ final class Hypercart_Query_Guard { const THROTTLE_LEVEL_ELEVATED = 'elevated'; const THROTTLE_LEVEL_CRITICAL = 'critical'; + /** + * Max unscoped order-note diagnostic log lines per request. Volume + * guard for the optional order-notes caller probe — the unscoped + * load normally fires once per request, but a looping caller is + * capped here so it can never flood the log. + */ + const ORDER_NOTES_LOG_MAX_PER_REQUEST = 5; + + /** + * Count of order-note diagnostic lines emitted this request. + * + * @var int + */ + private static $order_notes_log_count = 0; + /** * Default throttle policy by load level. */ @@ -194,6 +214,14 @@ public static function init() { $mode = self::get_mode(); $throttle_mode = self::get_throttle_mode(); + // Optional diagnostic, independent of mode/throttle: log the PHP + // caller of the unscoped "order-notes mega-query". Registered + // before the early-return so it works even when the guard is + // otherwise off. Disabled unless explicitly enabled in wp-config. + if ( self::order_notes_probe_enabled() ) { + add_action( 'pre_get_comments', array( __CLASS__, 'log_unscoped_order_notes' ), 1 ); + } + if ( self::MODE_OFF === $mode && self::THROTTLE_MODE_OFF === $throttle_mode ) { return; } @@ -251,6 +279,92 @@ public static function maybe_run_diagnostic_query() { $wpdb->query( 'SELECT SLEEP(6)' ); } + /** + * Whether the unscoped order-note caller probe is enabled. + * + * Off unless define( 'HYPERCART_QUERY_GUARD_LOG_ORDER_NOTES', true ) + * is set in wp-config.php. Also overridable via the + * 'hypercart_query_guard_log_order_notes' filter. + * + * @return bool + */ + private static function order_notes_probe_enabled() { + $enabled = defined( 'HYPERCART_QUERY_GUARD_LOG_ORDER_NOTES' ) && HYPERCART_QUERY_GUARD_LOG_ORDER_NOTES; + + return (bool) apply_filters( 'hypercart_query_guard_log_order_notes', $enabled ); + } + + /** + * Diagnostic probe for the "order-notes mega-query". + * + * WooCommerce's wc_get_order_notes() maps order_id => post_id and then + * calls get_comments() with type 'order_note' and no LIMIT. When a + * caller invokes it with no order_id / order__in, the query is + * unscoped: WordPress gathers the IDs of every order note on the site + * and primes the comment cache in one giant + * `SELECT wp_comments.* FROM wp_comments WHERE comment_ID IN (...)`, + * scanning ~9M rows and growing without bound. + * + * The slow-query log only records the immediate caller + * (wc-order-functions.php), not who called it unscoped. This fires on + * pre_get_comments — before the expensive query runs — and records a + * full backtrace so the originating caller can be identified. + * + * Scoped per-order loads (the normal, cheap case) are ignored, so this + * logs only the problematic unscoped call. Enabled only when + * HYPERCART_QUERY_GUARD_LOG_ORDER_NOTES is truthy. + * + * @param WP_Comment_Query $query Comment query (passed by reference; not modified). + * @return void + */ + public static function log_unscoped_order_notes( $query ) { + if ( ! is_object( $query ) || empty( $query->query_vars ) ) { + return; + } + + $vars = $query->query_vars; + + // WooCommerce order notes only. + if ( ! isset( $vars['type'] ) || 'order_note' !== $vars['type'] ) { + return; + } + + // Scoped per-order loads are cheap and expected. Only the unscoped + // "load every note" call is the performance problem. + if ( ! empty( $vars['post_id'] ) || ! empty( $vars['post__in'] ) ) { + return; + } + + // Bound per-request volume in case a caller loops. + if ( self::$order_notes_log_count >= self::ORDER_NOTES_LOG_MAX_PER_REQUEST ) { + return; + } + self::$order_notes_log_count++; + + // Caller chain. Skip this handler and the hook-dispatch frames so + // get_comments() -> wc_get_order_notes() -> originating caller + // surface at the front of the trace. + $trace = function_exists( 'wp_debug_backtrace_summary' ) + ? wp_debug_backtrace_summary( null, 3, false ) + : array(); + if ( is_array( $trace ) ) { + $trace = implode( ' < ', $trace ); + } + + self::log( + 'warn', + array( + 'event' => 'order_notes_unscoped', + 'context' => self::detect_context(), + 'action' => isset( $_REQUEST['action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['action'] ) ) : '', + 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '', + 'referer' => isset( $_SERVER['HTTP_REFERER'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '', + 'limit' => isset( $vars['number'] ) ? (int) $vars['number'] : 0, + 'caller' => self::truncate( (string) $trace, 1200 ), + ) + ); + } + /** * Resolve current operating mode. *