Skip to content

Feature/sql classifier rebased#41

Open
mrtwebdesign wants to merge 2 commits into
developmentfrom
feature/sql-classifier-rebased
Open

Feature/sql classifier rebased#41
mrtwebdesign wants to merge 2 commits into
developmentfrom
feature/sql-classifier-rebased

Conversation

@mrtwebdesign
Copy link
Copy Markdown
Contributor

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. A wp_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 repeated MySQL server has gone away errors. 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_ajax ceiling: 20 s → 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. checkout (60 s) and wp_admin (45 s) are unchanged. The hypercart_query_guard_limit_ms filter allows operators to relax specific $_REQUEST['action'] values without touching the global ceiling.

classify_sql( $sql ): array — new public static method

Cheap, safe SQL shape detection: one anchored preg_match locates the first IN ( position, then substr_count counts commas in the list body (O(n), no backtracking). Everything else is stripos. No SQL mutation.

Returns six fields:

Field Description
table_hint 'wp_comments' when that table appears in the SQL, else ''
is_comment_query true when table_hint === 'wp_comments'
has_large_in_list true when an IN (…) list has ≥ 200 items (LARGE_IN_LIST_THRESHOLD const)
estimated_in_list_size Comma count + 1 of the first IN (…) found; 0 if none
is_probable_woocommerce true when WooCommerce table/keyword hints are present
is_probable_order_note_query true when query targets wp_comments and mentions order_note

Only classifies SELECT; writes return all-default values.

is_admin_ajax_request( $uri ): bool — new public static helper

Simple strpos on 'admin-ajax.php'. Public for testability.

7 new structured fields on slow_query and query_killed events

Both 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_payload filter

Fires 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 config
  • tests/bootstrap.php — minimal WordPress function stubs
  • tests/SqlClassifierTest.php — 22 tests (classifier edge cases, write-query exclusion, WC heuristics, boundary conditions)
  • tests/PayloadFieldsTest.php — 13 tests (both query_killed and slow_query payload shapes, admin-ajax URI detection in context)

35 tests, 117 assertions, all passing.


Non-goals (not changed)

  • No WooCommerce batching
  • No SQL mutation
  • No Action Scheduler throttle logic changes
  • No special-case query rewriting

noelsaw1 added 2 commits June 1, 2026 15:57
…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).
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_ajax MAX_EXECUTION_TIME ceiling from 20s to 10s and extend slow_query / query_killed payloads with is_admin_ajax + 6 SQL classification fields.
  • Add Hypercart_Query_Guard::classify_sql() and ::is_admin_ajax_request() helpers, plus a hypercart_query_guard_log_payload filter hook inside log().
  • 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.0 section there are two ### Added headings 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 ### Added section (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.

Comment thread hypercart-query-guard.php
return;
}

$uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '';
Comment thread hypercart-query-guard.php
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 thread hypercart-query-guard.php
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 thread README.md
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) |
Comment thread CHANGELOG.md

### 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`).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants