Hypercart Query Guard
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.
by Hypercart / Neochrome · github.com/hypercart-dev-tools/queryguard-plugin · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/hypercart-dev-tools/queryguard-plugin/archive/refs/heads/main.zipReadme
Hypercart Query Guard
A PHP-side circuit breaker for WordPress that enforces MySQL MAX_EXECUTION_TIME on read queries to prevent runaway SELECTs 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.
What it does
- Sets
MAX_EXECUTION_TIMEper MySQL session at the start of each request, so MySQL itself kills anySELECTexceeding the configured ceiling (default 30 seconds, tiered by request context). - Detects connection rotation (managed hosts pool/rotate MySQL connections) and re-applies the session limit automatically — no heartbeat needed.
- Logs killed queries with their URI, calling user, and context for post-incident analysis.
- Surfaces a recovery notice in
wp-adminwhen an admin search is killed, so the user sees "Search timed out, try a more specific query" instead of a misleading "no results found" (which causes them to retry and re-trigger the runaway). - Ships a SAVEQUERIES-based observe mode for safe rollout: identifies queries that would be killed before you turn enforcement on.
- Ships a Phase 1 Action Scheduler throttle with managed-host-safe
test_observe,observe, andenforcemodes so you can validate load signals before changing queue-runner behavior.
What it does not do
- Does not kill
UPDATE,DELETE, orINSERT.MAX_EXECUTION_TIMEis read-only by MySQL design, and that's intentional — killing a write mid-transaction can corrupt order state, Action Scheduler claims, or HPOS sync tables. - Does not address lock contention (queries waiting on row locks rather than executing). For that you need
innodb_lock_wait_timeout, which is a server-level setting on most managed hosts. - Does not rate-limit query volume. A flood of fast queries can still saturate CPU; this plugin only catches individually slow queries.
Installation
Copy all plugin PHP files into wp-content/mu-plugins/:
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
No activation step. MU-plugins load automatically.
Configuration
Add to wp-config.php:
define( 'HYPERCART_QUERY_GUARD_MODE', 'observe' );
define( 'HYPERCART_QUERY_GUARD_THROTTLE_MODE', 'off' );
Modes:
off— plugin loads but does nothing. Use to disable without uninstalling.observe(default) — noMAX_EXECUTION_TIMEset, no kills. Samples 5% of requests, logs queries that exceed 5 seconds. Safe to run in production for a week before flipping to enforce.enforce— applies the tiered ceiling, kills runaway queries, logs every kill, and shows the admin recovery notice.
Action Scheduler throttle modes:
off(default) — no load probing and no queue-runner throttling.test_observe— probes load signals and logs capability / probe timing information during Action Scheduler runs without changing queue behavior. Start here on managed hosts.observe— computes the effective throttle decision and logs what would happen, but does not alter queue-runner behavior.enforce— applies the queue-runner throttle policy based on the detected load level.
Wave B per-action deferral defaults:
| Load | Critical | High | Normal | Deferrable |
|---|---|---|---|---|
| Elevated | run | run | +5 min | +15 min |
| Critical | run | +5 min | +15 min | +60 min |
Throttle policy defaults:
| Load | Batch Size | Time Limit | Concurrent Batches |
|---|---|---|---|
| Normal | default | default | default |
| Elevated | 5 | 15s | 1 |
| Critical | 1 | 10s | 1 |
Throttle tuning filters:
add_filter( 'hypercart_query_guard_throttle_mode', function( $mode ) {
return $mode;
} );
add_filter( 'hypercart_query_guard_throttle_enabled', function( $enabled, $mode ) {
return $enabled;
}, 10, 2 );
add_filter( 'hypercart_query_guard_load_thresholds', function( $thresholds ) {
$thresholds['queue_depth_critical'] = 250;
return $thresholds;
} );
add_filter( 'hypercart_query_guard_throttle_policy', function( $policy ) {
$policy['critical']['time_limit'] = 8;
return $policy;
} );
add_filter( 'hypercart_query_guard_action_delay_matrix', function( $matrix ) {
$matrix['critical']['deferrable'] = 1800;
return $matrix;
} );
add_filter( 'hypercart_query_guard_throttle_require_persistent_cache', '__return_true' );
Tiered limits
Limits are applied per request context. Override via the hypercart_query_guard_limit_ms filter:
| Context | Default ceiling | Rationale |
|---|---|---|
| WP-CLI | unlimited | Long-running migrations, imports |
| Action Scheduler | unlimited | Background workers, writes anyway |
wp-cron.php |
10 seconds | Cron should never be slow; cheap signal |
admin-ajax.php |
20 seconds | Most spike vectors live here (FB sync, NoFraud) |
| REST API | 30 seconds | Klaviyo polling, WC REST /orders |
| Frontend (default) | 30 seconds | Product pages, my-account |
wp-admin |
45 seconds | Admin search/filter operations tolerated longer |
| Checkout | 60 seconds | Coupon validation, fraud checks, shipping calc |
Filter example:
add_filter( 'hypercart_query_guard_limit_ms', function( $ms, $context ) {
if ( $context === 'rest_api' && strpos( $_SERVER['REQUEST_URI'], 'klaviyo' ) !== false ) {
return 15000; // tighter ceiling for Klaviyo specifically
}
return $ms;
}, 10, 2 );
Logging
If Hypercart_Logger (from the Hypercart Performance Monitor plugin) is present, log lines route through it. Otherwise the plugin falls back to error_log() with single-line JSON for grep-ability:
[hypercart_query_guard][error] {"event":"query_killed","context":"admin_ajax","limit_ms":20000,"last_query":"SELECT * FROM wp_nf_transactions WHERE meta_key = '_nofraud_transaction_status_workaround'","uri":"/wp-admin/admin-ajax.php","user_id":0,"time":1745875234}
Event types:
slow_query(warn) — query exceeded 5s but completed; sampled in observe mode, always in enforce mode.query_killed(error) — MySQL killed the query for hitting the limit; only emitted in enforce mode.as_throttle_capability_test(info) — emitted intest_observe; includes signal availability, cache backend, and probe timing.as_throttle_observed(info) — emitted in throttleobserve; logs the would-be Action Scheduler throttle decision.as_throttle_applied(info) — emitted in throttleenforce; logs the effective Action Scheduler throttle decision.as_action_deferred(info) — emitted when an individual Action Scheduler job is deferred by priority tier and load level. Inobservemode, deduped per (hook, level) per request to bound log volume during sustained load.as_action_deferral_skipped(info) — emitted when a defer is suppressed:reason: max_defers_reachedafter the per-(hook, args, group) defer cap is hit, or recurring schedules that the throttle refuses to interfere with.as_action_defer_failed(warn) — emitted whensave_action()returns 0 or throws while creating the deferred clone.load_level_transition(info) — emitted when the throttle load level changes across requests.
Limitations and caveats
-
initpriority 1 is not the earliest possible hook — early-boot queries are unprotected. TheSET SESSIONis applied oninitpriority 1, so anything that queries the database before then runs without the ceiling. In practice the unprotected window contains:- The autoloaded options preload (
wp_load_alloptions()) — a singleSELECT … FROM wp_options WHERE autoload = 'yes'. Slow only on sites with bloatedwp_options(10k+ autoloaded rows from plugins that never clean up). - User and usermeta lookups during auth (
determine_current_user,wp_validate_auth_cookie). - WooCommerce session bootstrap on
plugins_loadedpriority 10 (WC_Session_Handler::init), which can hitwp_woocommerce_sessionsandwp_actionscheduler_*. - Anything plugins do on
muplugins_loaded,plugins_loaded, orsetup_theme— including object-cache warmup queries.
The v2 path that closes this gap is a
wp-content/db.phpdrop-in that setsMAX_EXECUTION_TIMEat connection time (insidewpdb::db_connect()), before the first query is possible. v1 covers everything frominitonward, which empirically catches the failure modes this plugin was built for (admin search, REST polling, AJAX vectors, background sync). Signs you need v2: kills logged withcontext: frontendand alast_queryagainstwp_options, repeated alloptions slow-query warnings in observe mode, or any kill whose stack trace points intoplugins_loaded. - The autoloaded options preload (
-
SAVEQUERIEShas memory cost. Observe mode samples 5% of requests by default to keep overhead bounded. Don't run observe at 100% sampling on a high-traffic site. -
WP Engine reconnects. WPE's MySQL proxy occasionally rotates connections mid-request. The static
$last_dbhidentity check detects this and re-applies the limit automatically. -
Older MySQL.
MAX_EXECUTION_TIMErequires MySQL 5.7.8+ or Percona/MariaDB equivalents. The plugin suppresses errors on theSET SESSIONitself, so an unsupported server fails open (no protection, no breakage). -
Managed-host throttling relies heavily on queue depth.
SHOW STATUS LIKE 'Threads_running'is often blocked on WP Engine, Kinsta, and similar platforms, so the Action Scheduler throttle treats queue depth as the practical primary signal and logs whetherThreads_runningwas available. -
Throttle hysteresis needs cross-request state. The plugin prefers a persistent object cache, falls back to APCu, and finally falls back to a low-write WordPress option storing only the current level and last transition timestamp.
-
WP-CLI queue runs are only partially covered by the Phase 1 throttle. Action Scheduler's WP-CLI runner takes its batch size from the CLI command arguments, not the
action_scheduler_queue_runner_batch_sizefilter, so web-runner throttling and CLI-runner throttling are not identical. -
Wave B currently defers before execution, not before claim. Deferred actions are rescheduled and the current claimed action is canceled/unclaimed in
action_scheduler_before_execute. That keeps the expensive callback from running, but it does not remove all claim churn under load. -
Wave B does not defer recurring actions. Cancelling a recurring instance breaks Action Scheduler's recurrence chain (the chain advances only on a successful execution path), and re-rooting a new chain at
now+delaysilently shifts the cadence — for cron schedules, an entire scheduled tick can be missed. The throttle leaves recurring actions alone; consider usingaction_scheduler_queue_runner_concurrent_batchesand the Wave A queue-runner throttle to shed recurring load instead. -
Wave B caps deferrals per action signature. A given (hook, args, group) is deferred at most 5 times within a 1-hour window before the throttle lets it run. Filter
hypercart_query_guard_max_defer_countto tune. The cap is best-effort — counters live in the object cache and reset per request on hosts without a persistent cache. -
Each defer creates a new
wp_actionscheduler_actionsrow. 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. Monitorwp_actionscheduler_actionsrow 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_actionsindex, but it is still a real SQL query. Usetest_observefirst and inspect the logged probe timings before enablingenforce.
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.
Origin
Built in response to a CPU-saturation incident on a high-volume WooCommerce store where a single Facebook background sync query (SELECT … FROM wp_comments WHERE comment_ID IN (… 10,010 items …)) running concurrently with itself took down the whole pod. The kill switch is the cheapest insurance against that class of failure: a few hours of work, indefinite payoff.