WIP: Performance Bug: Triple self-join meta_query in global variable rename and class usage scan

Performance Bug: Triple self-join meta_query in global variable rename and class usage scan

Summary

Several Bricks functions use WP_Query with a meta_query using relation => 'OR' and compare => 'EXISTS' across three meta keys. WordPress translates this into a triple INNER JOIN on wp_postmeta, which creates a cartesian product that examines millions of rows even on small sites. This causes multi-minute query times and can monopolize a shared database server’s CPU.

Affected Functions

  • Helpers::update_global_variable_references() (includes/helpers.php ~line 4318)
  • Helpers::get_global_classes_usage() (includes/helpers.php ~line 3700)

Both contain this query pattern:

$query = new \WP_Query([
    'post_type'      => $post_types,
    'posts_per_page' => -1,
    'fields'         => 'ids',
    'post_status'    => 'any',
    'meta_query'     => [
        'relation' => 'OR',
        ['key' => BRICKS_DB_PAGE_HEADER, 'compare' => 'EXISTS'],
        ['key' => BRICKS_DB_PAGE_CONTENT, 'compare' => 'EXISTS'],
        ['key' => BRICKS_DB_PAGE_FOOTER, 'compare' => 'EXISTS'],
    ],
]);

The Problem

WordPress’s WP_Meta_Query translates each OR clause into a separate INNER JOIN on wp_postmeta, producing this SQL:

SELECT wp_posts.ID
FROM wp_posts
  INNER JOIN wp_postmeta ON (wp_posts.ID = wp_postmeta.post_id)
  INNER JOIN wp_postmeta AS mt1 ON (wp_posts.ID = mt1.post_id)
  INNER JOIN wp_postmeta AS mt2 ON (wp_posts.ID = mt2.post_id)
WHERE 1=1
  AND (wp_postmeta.meta_key = '_bricks_page_header_2'
    OR mt1.meta_key = '_bricks_page_content_2'
    OR mt2.meta_key = '_bricks_page_footer_2')
  AND wp_posts.post_type IN ('bricks_template', 'page', 'post')
  AND ((wp_posts.post_status <> 'trash' AND wp_posts.post_status <> 'auto-draft'))
GROUP BY wp_posts.ID
ORDER BY wp_posts.post_date DESC

Each INNER JOIN matches every postmeta row for a given post (not just the target meta_key), because the OR condition spans different table aliases and MySQL cannot push the filter down into individual joins. With an average of ~56 meta rows per post, the joins multiply: 56 x 56 x 56 = ~175,000 row combinations per post.

Measured impact on a small site (386 posts, 34K postmeta rows)

Metric Value
Posts scanned 313
Rows examined per post 51 x 51 x 51 = ~132,651
Total rows examined ~41,500,000
Query execution time ~4 minutes
Requires temporary table Yes
Requires filesort Yes

MySQL’s EXPLAIN output:

id  table         type   key      rows  Extra
1   wp_posts      index  PRIMARY  313   Using where; Using temporary; Using filesort
1   wp_postmeta   ref    post_id  51
1   mt1           ref    post_id  51
1   mt2           ref    post_id  51    Using where

Additionally, update_global_variable_references() is called once per renamed variable from ajax.php (~line 2035), so renaming N variables runs this query N times.

The Fix

The three OR / EXISTS clauses are semantically identical to a single IN() query. Replace:

$query = new \WP_Query([
    'post_type'      => $post_types,
    'posts_per_page' => -1,
    'fields'         => 'ids',
    'post_status'    => 'any',
    'meta_query'     => [
        'relation' => 'OR',
        ['key' => BRICKS_DB_PAGE_HEADER, 'compare' => 'EXISTS'],
        ['key' => BRICKS_DB_PAGE_CONTENT, 'compare' => 'EXISTS'],
        ['key' => BRICKS_DB_PAGE_FOOTER, 'compare' => 'EXISTS'],
    ],
]);
$post_ids = $query->posts;

With a direct $wpdb query:

global $wpdb;

$post_type_placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
$sql = $wpdb->prepare(
    "SELECT DISTINCT p.ID
     FROM {$wpdb->posts} p
     INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
     WHERE pm.meta_key IN (%s, %s, %s)
       AND p.post_type IN ({$post_type_placeholders})
       AND p.post_status NOT IN ('trash', 'auto-draft')
     ORDER BY p.post_date DESC",
    BRICKS_DB_PAGE_HEADER,
    BRICKS_DB_PAGE_CONTENT,
    BRICKS_DB_PAGE_FOOTER,
    ...$post_types
);
$post_ids = $wpdb->get_col($sql);

Performance comparison

Original (triple JOIN + OR) Fixed (single JOIN + IN)
Rows examined ~41,500,000 29
Execution time ~4 minutes ~1 millisecond
Temporary table Yes No

MySQL’s EXPLAIN for the fixed query:

id  table  type    key             rows  Extra
1   pm     range   meta_key_value  29    Using where; Using index; Using temporary; Using filesort
1   p      eq_ref  PRIMARY         1     Using where

Additional optimization

In ajax.php (~line 2022), the loop that calls update_global_variable_references() once per renamed variable could be further optimized by querying the post IDs once outside the loop and passing them in, rather than re-querying for every variable rename.

Environment

  • Bricks 2.2
  • WordPress 6.9.1
  • MySQL 8.0
  • Site with 386 posts and 34,191 postmeta rows

Workaround

Until this is fixed, the query can be intercepted and rewritten via the posts_clauses filter in a child theme.

3 Likes

Hi @kevin-clicks,

Thank you for bringing this to our attention. I have moved this to the improvements category and added it to our to-do list.