Skip to content
Closed
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 .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
/.claude
/vendor
PROJECT/.DS_Store
composer.lock
PROJECT/1-INBOX/.DS_Store
PROJECT/2-WORKING/.DS_Store
PROJECT/3-DONE/.DS_Store
PROJECT/4-MISC/.DS_Store
.phpunit.result.cache
22 changes: 19 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,25 @@

All notable changes to Hypercart Query Guard are documented here.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Every merged build carries a version number.

## [Unreleased]
## [1.1.0] — 2026-05-30

### Changed

- **`admin_ajax` execution-time ceiling tightened from 20 s to 10 s.** `admin-ajax.php` is the primary vector for runaway read queries (WC order-note loading, Facebook background sync, NoFraud). Halving the ceiling halves the per-execution DB exposure when concurrent workers pile up. Legitimate heavyweight reads that need > 10 s should use the REST API (30 s ceiling) or `wp-admin` (45 s ceiling). The `checkout` context (60 s) is unaffected — it is detected before `admin_ajax`. Operators can relax individual endpoints via the `hypercart_query_guard_limit_ms` filter.

### Added

- **SQL shape classifier (`classify_sql`).** A new `public static` method inspects a SQL string cheaply (plain string operations + one anchored regex for `IN (`) and returns six classification fields: `table_hint`, `is_comment_query`, `has_large_in_list`, `estimated_in_list_size`, `is_probable_woocommerce`, and `is_probable_order_note_query`. Only `SELECT` statements are classified; writes return all-default values. The classifier's large-IN threshold is 200 items (constant `LARGE_IN_LIST_THRESHOLD`).

- **`is_admin_ajax_request` helper.** Public static method — takes a URI string and returns `true` when it routes through `admin-ajax.php`. Used for logging; no behaviour change.

- **Seven new structured fields on `slow_query` and `query_killed` log events.** Both event types now carry `is_admin_ajax`, `table_hint`, `is_comment_query`, `has_large_in_list`, `estimated_in_list_size`, `is_probable_woocommerce`, and `is_probable_order_note_query`. These fields directly address the May 2026 production incident where a WooCommerce order-note query (`wp_comments … comment_ID IN (… 10,639 IDs …)`) running via `admin-ajax.php` consumed a large share of DB time and caused repeated `MySQL server has gone away` errors.

- **`hypercart_query_guard_log_payload` filter.** Fires inside `log()` before emission. Allows callers to add custom fields, route to additional sinks, or inspect payloads in integration tests.

- **PHPUnit test suite.** `phpunit.xml`, `tests/bootstrap.php` (WordPress function stubs), `tests/SqlClassifierTest.php` (22 tests covering classifier edge cases), and `tests/PayloadFieldsTest.php` (13 tests verifying both `query_killed` and `slow_query` payload shapes). 35 tests, 117 assertions.

### Fixed

Expand All @@ -16,6 +32,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

- **Every killed query in a request is now logged, not just the last one.** `detect_and_log_kill()` previously ran only on `shutdown` and read `$wpdb->last_error`, which gets overwritten by each subsequent query. A request that triggered N kills produced exactly one log line — the final one. Capture is now incremental: a `query` filter callback inspects `$wpdb->last_error` before each subsequent `wpdb::query()` clears it via `flush()`, and a high-water mark on `$wpdb->num_queries` makes the capture idempotent so duplicate error strings (the NoFraud thundering-herd pattern) are still counted as distinct kills. Shutdown remains the fallback for the request's final query.

## [1.0.0]
## [1.0.0] — 2026-05-01

Initial release.
61 changes: 59 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Limits are applied per request context. Override via the `hypercart_query_guard_
| 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) |
| `admin-ajax.php` | 10 seconds | Most spike vectors live here (FB sync, WC order-note IN lists, NoFraud). Tightened from 20 s to reduce per-execution DB exposure; heavyweight reads should use REST API or `wp-admin` instead. |
| 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 |
Expand All @@ -78,9 +78,43 @@ If `Hypercart_Logger` (from the Hypercart Performance Monitor plugin) is present

Two event types:

- **`slow_query`** *(warn)* — query exceeded 5s but completed; sampled in observe mode, always in enforce mode.
- **`slow_query`** *(warn)* — query exceeded 5 s 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.

### SQL classification fields

Both events carry additional classification fields derived from cheap, safe string inspection of the SQL. These fields are designed for post-incident filtering and alerting.

| Field | Type | Description |
|---|---|---|
| `is_admin_ajax` | bool | `true` when the request URI routes through `admin-ajax.php` |
| `table_hint` | string | `'wp_comments'` when that table appears in the SQL, else `''` |
| `is_comment_query` | bool | `true` when `table_hint === 'wp_comments'` |
| `has_large_in_list` | bool | `true` when the SQL contains an `IN (…)` list with ≥ 200 items |
| `estimated_in_list_size` | int | Comma count + 1 inside the first `IN (…)` found; `0` if none |
| `is_probable_woocommerce` | bool | `true` when WooCommerce table/keyword hints are present |
| `is_probable_order_note_query` | bool | `true` when the query targets `wp_comments` and mentions `order_note` (WC stores order notes as comment rows) |

The classifier only runs on `SELECT` statements; writes return all-default values. No SQL mutation occurs.

### Filtering the log payload

A `hypercart_query_guard_log_payload` filter fires before each log emission. Use it to add custom fields, route to additional sinks, or tighten alerting:

```php
add_filter( 'hypercart_query_guard_log_payload', function( $payload, $level ) {
// Page-level alert for killed order-note queries from admin-ajax.
if (
$level === 'error' &&
$payload['is_probable_order_note_query'] &&
$payload['is_admin_ajax']
) {
my_pagerduty_alert( $payload );
}
return $payload;
}, 10, 2 );
```

## Limitations and caveats

- **`init` priority 1 is not the earliest possible hook — early-boot queries are unprotected.** The `SET SESSION` is applied on `init` priority 1, so anything that queries the database before then runs without the ceiling. In practice the unprotected window contains:
Expand All @@ -94,10 +128,33 @@ Two event types:
- **WP Engine reconnects.** WPE's MySQL proxy occasionally rotates connections mid-request. The static `$last_dbh` identity check detects this and re-applies the limit automatically.
- **Older MySQL.** `MAX_EXECUTION_TIME` requires MySQL 5.7.8+ or Percona/MariaDB equivalents. The plugin suppresses errors on the `SET SESSION` itself, so an unsupported server fails open (no protection, no breakage).

## Enforce-mode rollout recommendation

For stores exposed to high-frequency admin-ajax traffic (WooCommerce order management, background syncs, order-note loading):

1. **Start in observe mode for at least 48 hours.** Watch for `slow_query` events with `is_admin_ajax: true`, `is_comment_query: true`, or `has_large_in_list: true`. These identify the queries that enforce mode will kill.
2. **Check Action Scheduler detection.** If you see `slow_query` events with `context: admin_ajax` from queries you know are AS workers, verify the AS action names in `detect_context()` match your version. The static memo in `apply_session_timeout()` locks in the tier on first call; a mis-detected AS worker gets a 10 s ceiling for the rest of that request.
3. **Enable enforce mode.** Set `define( 'HYPERCART_QUERY_GUARD_MODE', 'enforce' )` in `wp-config.php`. Monitor `query_killed` events for 24 hours. Any killed query with `event: query_killed`, `is_probable_order_note_query: true`, `has_large_in_list: true`, and `is_admin_ajax: true` is the exact failure mode this plugin was hardened for.
4. **If a legitimate endpoint needs > 10 s,** use the limit filter to relax that specific action — do not raise the global `admin_ajax` ceiling:

```php
add_filter( 'hypercart_query_guard_limit_ms', function( $ms, $context ) {
if ( $context === 'admin_ajax' && isset( $_REQUEST['action'] ) ) {
// Relax only for the specific WC AJAX action that legitimately needs more time.
if ( $_REQUEST['action'] === 'my_slow_but_necessary_action' ) {
return 30000;
}
}
return $ms;
}, 10, 2 );
```

## 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.

On 2026-05-30 a production store suffered the same shape of incident via WooCommerce order-note loading: `comment_ID IN (… 10,639 IDs …)` fired repeatedly through `admin-ajax.php`, consumed large DB time per execution, and generated repeated `MySQL server has gone away` errors. The SQL classification fields and 10 s `admin_ajax` ceiling were added in response.

## License

This plugin is licensed under the **GNU General Public License v2.0 or later** (GPL-2.0-or-later), the same license as WordPress itself.
Expand Down
Loading