Feature/sql classifier rebased#41
Open
mrtwebdesign wants to merge 2 commits into
Open
Conversation
…uite Motivation ---------- On 2026-05-30 a production WooCommerce store suffered a DB saturation incident via admin-ajax.php. A `wp_comments WHERE comment_ID IN (… 10,639 IDs …)` query fired repeatedly through order-note loading, consumed large DB time per execution, and generated repeated "MySQL server has gone away" errors. This commit reduces blast radius for that exact class of failure and adds the logging fields needed to identify it in future incidents. Changes ------- * admin_ajax ceiling: 20 s → 10 s Halves per-execution DB exposure when concurrent ajax workers pile up. Checkout (60 s) and wp_admin (45 s) are unchanged. Operators can relax specific actions via hypercart_query_guard_limit_ms filter. * classify_sql( $sql ): array — public static Cheap, safe SQL shape detection (one anchored preg_match + substr_count). Returns: table_hint, is_comment_query, has_large_in_list, estimated_in_list_size, is_probable_woocommerce, is_probable_order_note_query. Only classifies SELECT; writes return all-default values. Threshold for "large" IN list: 200 items (LARGE_IN_LIST_THRESHOLD const). * is_admin_ajax_request( $uri ): bool — public static Simple strpos check; public for testability. * Both slow_query and query_killed events carry all 7 new fields: is_admin_ajax + the 6 classifier fields. * hypercart_query_guard_log_payload filter added inside log() so callers can add custom fields, route to extra sinks, or intercept payloads in integration tests. * PHPUnit suite: phpunit.xml, tests/bootstrap.php (WP function stubs), tests/SqlClassifierTest.php (22 tests), tests/PayloadFieldsTest.php (13 tests). 35 tests, 117 assertions. * README: updated admin_ajax limit table entry, expanded Logging section with classification field reference, added enforce-mode rollout guide. * CHANGELOG: Unreleased block documenting all changes.
…t first) - apply_filters null-return guard: replace bare (array) cast with is_array+!empty check so a hook callback that omits return $payload no longer silently reduces the log payload to []. - preg_match -> preg_match_all in classify_sql: scan every IN ( occurrence and keep the maximum list size, preventing a subquery IN earlier in the query from masking a large literal IN list. - tests/bootstrap.php: capture wp_json_encode argument in $GLOBALS['_qg_last_encoded'] for reliable emission-side assertions. - Add 3 regression tests: 2 for subquery masking (SqlClassifierTest), 1 for null-return filter guard (PayloadFieldsTest).
There was a problem hiding this comment.
Pull request overview
This PR rebases and delivers the SQL “shape” classifier + structured logging additions intended to reduce the blast radius of runaway admin-ajax.php read queries (notably WooCommerce order-note wp_comments ... IN (...) patterns), while adding a PHPUnit suite to lock in the behavior.
Changes:
- Tighten the
admin_ajaxMAX_EXECUTION_TIME ceiling from 20s to 10s and extendslow_query/query_killedpayloads withis_admin_ajax+ 6 SQL classification fields. - Add
Hypercart_Query_Guard::classify_sql()and::is_admin_ajax_request()helpers, plus ahypercart_query_guard_log_payloadfilter hook insidelog(). - Introduce PHPUnit configuration and unit tests covering SQL classification edge cases and payload field presence.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
hypercart-query-guard.php |
Adds SQL classifier + admin-ajax detection, appends new structured fields to log payloads, adds a payload filter hook, tightens admin_ajax ceiling, bumps version. |
tests/SqlClassifierTest.php |
New unit tests for classify_sql() and is_admin_ajax_request(). |
tests/PayloadFieldsTest.php |
New unit tests validating slow_query / query_killed payload shapes and filter behavior. |
tests/bootstrap.php |
Expands WP stubs for PHPUnit and adds helpers used by the new tests. |
phpunit.xml |
Adds PHPUnit runner configuration for the new test suite. |
README.md |
Documents new default limit and the new classification fields + payload filter. |
CHANGELOG.md |
Adds a 1.1.0 release section describing the new behavior and tests. |
Comments suppressed due to low confidence (1)
CHANGELOG.md:27
- Under the
1.1.0section there are two### Addedheadings back-to-back. This makes the changelog harder to scan and can confuse tooling/readers that expect unique headings per section. Merge these into a single### Addedsection (remove the duplicate heading).
- **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.
### Added
- **Phase 1 Action Scheduler throttle modes.** Query Guard now supports a separate `HYPERCART_QUERY_GUARD_THROTTLE_MODE` with `off`, `test_observe`, `observe`, and `enforce` modes. The throttle hooks Action Scheduler's web queue runner and can reduce batch size, time limit, and concurrent batches under load without changing the existing MySQL query-kill rollout model.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return; | ||
| } | ||
|
|
||
| $uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : ''; |
Comment on lines
+1248
to
+1251
| $filtered = apply_filters( 'hypercart_query_guard_log_payload', $payload, $level ); | ||
| if ( is_array( $filtered ) && ! empty( $filtered ) ) { | ||
| $payload = $filtered; | ||
| } |
Comment on lines
+1148
to
+1150
| * has_large_in_list – true when an IN list has >= LARGE_IN_LIST_THRESHOLD items | ||
| * estimated_in_list_size – item count (commas + 1) of the first IN list found, else 0 | ||
| * is_probable_woocommerce – true when WC table / keyword hints are found |
Comment on lines
+241
to
+244
| | `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) | |
|
|
||
| ### 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`). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is the same as PR #40 from @noelsaw1, I just rebased it on to a new branch from development.
Motivation
On 2026-05-30 a production WooCommerce store suffered a DB saturation incident via
admin-ajax.php. Awp_comments WHERE comment_ID IN (… 10,639 IDs …)query fired repeatedly through order-note loading, consumed a large share of DB time per execution, and generated repeatedMySQL server has gone awayerrors. This PR reduces blast radius for that exact class of failure and adds the logging fields needed to identify it in future incidents.Changes
admin_ajaxceiling: 20 s → 10 sadmin-ajax.phpis 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.checkout(60 s) andwp_admin(45 s) are unchanged. Thehypercart_query_guard_limit_msfilter allows operators to relax specific$_REQUEST['action']values without touching the global ceiling.classify_sql( $sql ): array— new public static methodCheap, safe SQL shape detection: one anchored
preg_matchlocates the firstIN (position, thensubstr_countcounts commas in the list body (O(n), no backtracking). Everything else isstripos. No SQL mutation.Returns six fields:
table_hint'wp_comments'when that table appears in the SQL, else''is_comment_querytruewhentable_hint === 'wp_comments'has_large_in_listtruewhen anIN (…)list has ≥ 200 items (LARGE_IN_LIST_THRESHOLDconst)estimated_in_list_sizeIN (…)found;0if noneis_probable_woocommercetruewhen WooCommerce table/keyword hints are presentis_probable_order_note_querytruewhen query targetswp_commentsand mentionsorder_noteOnly classifies
SELECT; writes return all-default values.is_admin_ajax_request( $uri ): bool— new public static helperSimple
strposon'admin-ajax.php'. Public for testability.7 new structured fields on
slow_queryandquery_killedeventsBoth events now carry
is_admin_ajax+ the 6 classifier fields. A killed incident query now looks like:{ "event": "query_killed", "context": "admin_ajax", "limit_ms": 10000, "last_query": "SELECT comment_ID … FROM wp_comments WHERE comment_ID IN (1, 2, …)[truncated]", "uri": "/wp-admin/admin-ajax.php?action=woocommerce_load_order_notes", "is_admin_ajax": true, "table_hint": "wp_comments", "is_comment_query": true, "has_large_in_list": true, "estimated_in_list_size": 10639, "is_probable_woocommerce": true, "is_probable_order_note_query": true }hypercart_query_guard_log_payloadfilterFires inside
log()before emission. Allows callers to add custom fields, route to additional sinks, or intercept payloads in integration tests.PHPUnit suite
phpunit.xml— test runner configtests/bootstrap.php— minimal WordPress function stubstests/SqlClassifierTest.php— 22 tests (classifier edge cases, write-query exclusion, WC heuristics, boundary conditions)tests/PayloadFieldsTest.php— 13 tests (bothquery_killedandslow_querypayload shapes, admin-ajax URI detection in context)35 tests, 117 assertions, all passing.
Non-goals (not changed)