Skip to content

feat: SQL classifier, 7 structured log fields, 10 s admin_ajax ceiling, PHPUnit suite#40

Closed
noelsaw1 wants to merge 3 commits into
developmentfrom
feature/enforce-mode-sql-classifier
Closed

feat: SQL classifier, 7 structured log fields, 10 s admin_ajax ceiling, PHPUnit suite#40
noelsaw1 wants to merge 3 commits into
developmentfrom
feature/enforce-mode-sql-classifier

Conversation

@noelsaw1
Copy link
Copy Markdown
Contributor

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 May 26, 2026 12:10
…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.
@noelsaw1 noelsaw1 force-pushed the feature/enforce-mode-sql-classifier branch from cbb68a0 to 875e551 Compare May 31, 2026 03:44
…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).
@mrtwebdesign
Copy link
Copy Markdown
Contributor

Commits moved to a rebased development branch PR #41

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.

2 participants