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
45 changes: 39 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -21,16 +27,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/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
```

**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/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/db.php
```

No activation step. MU-plugins load automatically.
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

Expand Down Expand Up @@ -221,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).
Expand Down
230 changes: 230 additions & 0 deletions db.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
<?php
/**
* Hypercart Query Guard — wp-content/db.php drop-in (v2).
*
* Extends wpdb with two capabilities:
* 1. Conditional backtracing — times every query (~0 cost), only calls
* debug_backtrace() on queries exceeding the warn threshold. Replaces
* SAVEQUERIES; eliminates the ~10% CPU overhead at 100% sample rate.
* 2. First-query SET SESSION MAX_EXECUTION_TIME — covers pre-init queries
* (wp_load_alloptions, auth/usermeta, WC session bootstrap) that the
* v1 mu-plugin cannot reach from its init-priority-1 hook.
*
* Install: copy this file to wp-content/db.php
* Remove: delete wp-content/db.php — mu-plugin reverts to v1 behavior.
* Requires: Hypercart Query Guard plugin for logging. Functions standalone
* for SET SESSION protection if the plugin is absent.
*
* wp-config.php constants (all optional):
* HYPERCART_QUERY_GUARD_MODE 'off'|'observe'|'enforce' (default: 'observe')
* HYPERCART_QUERY_GUARD_WARN_THRESHOLD_MS int, ms (default: 5000)
* HYPERCART_QUERY_GUARD_DEFAULT_LIMIT_MS int, ms for pre-init queries (default: 30000)
*
* @package Hypercart
*/

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

class HCQG_DB extends wpdb {

const DROPIN_VERSION = '2.0.0';

// Tested against WordPress 5.5 – 6.8. The override relies on query()
// returning int|bool and _do_query() being private (not called through
// the vtable). If core refactors _do_query into a protected method or
// changes query()'s signature, the assertion below will fire.
const WP_VERSION_FLOOR = '5.5';
const WP_VERSION_CEILING = '6.9';

/**
* Slow queries captured by conditional backtracing.
* Same shape as $wpdb->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;
}
Comment on lines +94 to +96

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();
Comment on lines +131 to +135
}

// 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 : ''
);
Loading