Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 115 additions & 1 deletion hypercart-query-guard.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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.
*
Expand Down