Skip to content

Observation blind spot: WPE's query governor blanks >16 KB SELECTs in PHP, producing zero slow_query/query_killed telemetry #47

Description

@mrtwebdesign

Summary

Query Guard's observation subsystem produces zero telemetry for queries killed by WP Engine's platform query governor — which are, by definition, the worst queries on the site. On bloomzhemp production, WPE's governor killed 7 WooCommerce order queries every night for six consecutive nights (42 events verified in the debug logs), while Query Guard — active in the same process, in enforce mode, with the v2 db.php drop-in installed — logged zero slow_query events and zero query_killed events for them across the entire period.

Flagged in Matthew's Jul 20 bloomzhemp log review (context: BinoidCBD/universal-child-theme-oct-2024#925). Root cause verified by Claude Code against the plugin source, WP core, and WP Engine's mu-plugin on production.

Root cause — the kill happens in PHP, before MySQL

WP Engine's governor (mu-plugins/wpengine-common/plugin.php, check_and_govern(), registered on the query filter at default priority) blocks oversized SELECTs by text length, not by runtime:

  • QUERY_LENGTH_MAX = 16384 — a SELECT longer than 16 KB is logged as KILLED QUERY (N characters long generated in file:line): … and the filter returns ''.
  • The nightly Awin order queries are 22–26 KB of WHERE ID IN (…) SQL, so they are blanked every time.

The empty string then hits WP core's early return in wpdb::query():

$query = apply_filters( 'query', $query );
if ( ! $query ) {
    $this->insert_id = 0;
    return false;
}

The query is never sent to MySQL, no error is raised, and $wpdb->last_error is never set. Every one of Query Guard's detection paths is downstream of an execution that never happens:

Detection path Why it stays silent
slow_query via v2 drop-in (db.php:143-158) parent::query() returns false in ~0 ms; $elapsed never reaches the 5 s WARN_THRESHOLD_MS, so the query is never appended to hcqg_slow_queries.
query_killed via capture_pending_kill() (hypercart-query-guard.php:1105-1124) Bails at the empty( $wpdb->last_error ) check — wpdb's early return sets no error. The two signature matches (maximum statement execution time, query execution was interrupted) are never even consulted.
Enforce-mode MAX_EXECUTION_TIME Session timeout only applies to queries MySQL executes; these never arrive.

Verified empirically: six days of production debug logs contain 42 KILLED QUERY lines and zero occurrences of any MySQL kill signature (query execution was interrupted, maximum statement execution time, lost connection, server has gone away). The kills are purely PHP-side.

Why this matters

  • The observation subsystem's implicit promise is "the slow-query log shows you your worst queries." Under a host governor, the very worst queries — bad enough that the host refuses to run them — are the only ones guaranteed to be invisible. An operator tuning from slow_query events alone would conclude these code paths are healthy.
  • The concrete consequence on bloomzhemp: the Awin approve_orders_daily() cron has its order queries blanked nightly, then fatals (get_id() on int) because WC_Order_Factory receives false instead of rows. Query Guard's logs offered no lead; root-causing required WPE's own log lines.
  • Related but distinct from Coverage gap: 87% of slow queries invisible to observation subsystem #43 (sampling/coverage gap) and from the duration-blind-spot noted in Queue-depth-only escalation throttles an idle database: Jul 20 flood ran 2h23m with threads_running ≤ 5 #46's evidence: even with the v2 drop-in's 100 % coverage, this class of query is structurally invisible, at any sample rate and any threshold.

Proposed fixes

  1. Detect governor blanking with a filter sandwich. Query Guard already registers on the query filter at priority 1 (hypercart-query-guard.php:249). Add a second capture at a very late priority (e.g. PHP_INT_MAX): the priority-1 hook stashes the incoming SQL in a static; the late hook fires after WPE's governor (priority 10 and 999) and, if the query arrives empty while the stashed original was non-empty, logs a new query_blocked_by_host event carrying the original SQL (truncated), length, context, and caller. This requires no knowledge of the host's implementation — any filter that blanks a query is caught.
  2. Log failed queries in the drop-in regardless of duration. HCQG_DB::query() currently records only elapsed >= threshold. Recording parent::query() === false results (with last_error, length, elapsed) would surface both this case and fast hard failures generally, cheaply.
  3. Broaden the query_killed signature list to cover connection-level kills (Lost connection to MySQL server during query, MySQL server has gone away), which also bypass the current two-pattern match.
  4. Leading indicator (optional): classify_sql() already estimates IN-list sizes. A warn-level event for SELECTs approaching the 16 KB governor limit (e.g. > 8 KB) would flag queries before the host starts silently blanking them.

Environment

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdxDeveloper experience, logging, observabilitypriority: highAddress before next release

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions