Technical Blog

Daily stories at Systems MW – Wadih Maalouf

I recently investigated repeated CPU health alerts on an anonymized production WordPress site. The site was still responding, memory was available, the disk was healthy, and there had been no out-of-memory event. The problem turned out to be a deterministic infinite loop in WP-Optimize’s page-cache preloader when the site had a sticky post.

This article documents the evidence, the incorrect theories I ruled out, the exact reproduction, and the mitigation I deployed. No client name, domain, address, content title, post ID, or user information is included.

Affected environment

ComponentVersion or configuration
WordPress7.1
PHP8.4.24
MariaDB11.8.6
WP-Optimize4.6.1
WPML4.9.7
Server4 virtual CPUs
WP-Optimize page cacheEnabled, scheduled preload enabled, sitemap preload disabled
Sticky postsOne

What triggered the investigation

An internal health check reported CPU-equivalent load of 101.2% against an 85% alert threshold. On a four-CPU host, the check converts the one-minute load average into a percentage by dividing it by four. A value above 100% therefore means more runnable work was queued than the four CPUs could immediately execute.

cpu [fail] value=101.2% threshold=<= 85.0%
ram [ok] value=28.0% threshold=<= 85.0%
disk [ok] value=57.0% threshold=<= 85.0%
oom-kills [ok] value=0

The alert repeated every five minutes for much of the next hour. This was not simply a website outage: an independent external monitor still received successful responses, with only one isolated HTTP 503 and no confirmed outage incident. That distinction mattered. The application was available, but background work was consuming most of the server.

The first useful evidence

Process sampling showed MariaDB using roughly 220% to 260% CPU. Three PHP workers owned by the WordPress virtual host had each been running inside wp-cron.php for approximately 2.5 to 4 hours. The database process list repeatedly showed three versions of the same WordPress query in the Creating sort index state.

SELECT SQL_CALC_FOUND_ROWS wp_posts.*
FROM wp_posts
LEFT JOIN wp_icl_translations ...
WHERE wp_posts.post_type IN ('post', 'page', 'attachment', 'project')
  AND wp_posts.post_status = 'publish'
ORDER BY wp_posts.ID ASC
LIMIT 1387302, 1000;

The exact offsets differed between workers and kept increasing. A later sample showed approximately 772,000, 877,000, and 1.39 million. This initially looked like a database containing millions of posts. It did not.

Database measurementObserved value
Total rows in wp_posts26,132
Rows matching the preload query296
Rows available at offset 2,000,0000

The million-scale values were query offsets, not records written to the database. The runaway process issued millions of unnecessary read iterations and temporary sorts. It did not create millions of persistent rows.

Tracing the query to WP-Optimize

The query signature matched WP_Optimize_Page_Cache_Preloader::get_post_urls(). When sitemap preloading does not supply URLs, WP-Optimize falls back to enumerating published content in batches of 1,000. Its logic is effectively:

do {
    $query = new WP_Query([
        'post_type'      => 'any',
        'post_status'    => 'publish',
        'posts_per_page' => 1000,
        'offset'         => $offset,
        'orderby'        => 'ID',
        'order'          => 'ASC',
        'cache_results'  => false,
    ]);

    $posts_loaded = $query->post_count;
    $offset += $posts_loaded;
} while ($posts_loaded > 0);

The loop assumes that post_count will eventually become zero. That assumption is normally reasonable, but this query does not set ignore_sticky_posts.

The false lead

I initially inspected filters attached to the_posts because the SQL query returned no rows while WP_Query still exposed one post. A gallery plugin had a callback on that hook, but its conditions did not match the cron request. Suppressing SQL filters also left the unexpected post in the result.

The decisive test was to run the same query twice at an offset of two million, changing only ignore_sticky_posts.

ignore_sticky_posts=false
is_home=true
post_count=1
found_posts=0
returned_post=<the site's sticky post>

ignore_sticky_posts=true
is_home=true
post_count=0
found_posts=0

MariaDB correctly returned zero rows in both cases. The extra post was inserted afterward by WordPress’s built-in sticky-post handling.

The root cause

WordPress considers this broad WP_Query a home query. The query uses an offset, but it does not set a later paged value. WordPress therefore sees it as page one and, unless ignore_sticky_posts is true, prepends sticky posts that were not present in the SQL result.

SQL reaches the end and returns zero rows
                  |
                  v
WordPress prepends the sticky post
                  |
                  v
WP-Optimize sees post_count = 1
                  |
                  v
offset increases by one and the loop repeats

The sticky post is fetched again on every iteration. The offset can grow indefinitely because the termination condition never becomes false. After cron and semaphore locks age out, later cron invocations can start overlapping copies of the same runaway job.

WPML was visible in the generated SQL and increased the cost of each iteration by adding translation joins and conditions. It amplified the CPU impact, but it did not cause the loop. The loop reproduced with query filters suppressed and stopped immediately when sticky-post placement was disabled.

Why this edge case can survive testing

  • The affected site must have at least one sticky post.
  • The preloader must fall back to querying posts instead of obtaining URLs from a sitemap.
  • The preload must run long enough for the iterator to reach the end of the real result set.
  • The server must allow the PHP request to continue after ordinary cron locks expire.
  • The visible symptom may be server load rather than an immediate fatal error.

A site with no sticky posts returns zero normally. A site whose sitemap supplies the preload URLs does not enter this fallback iterator. Those two common configurations hide the bug.

Suggested upstream fix

The required fix is to tell WordPress not to move sticky posts to the front of this bulk enumeration query:

'ignore_sticky_posts' => true,

WP-Optimize also does not use found_posts in this loop, so the query can avoid SQL_CALC_FOUND_ROWS as a separate performance improvement:

'no_found_rows' => true,

Both arguments belong in the query used by the post-based preloader. The sticky-post flag fixes correctness; the found-rows flag removes work that this iterator does not consume.

Update-resistant production mitigation

I did not edit the plugin’s vendor files because a plugin update would overwrite the change. Instead, I installed a small must-use plugin that recognizes the preloader’s distinctive query and sets the missing flag during pre_get_posts:

<?php
function guard_wp_optimize_preload_query(WP_Query $query): void
{
    if ('any' !== $query->get('post_type')
        || 'publish' !== $query->get('post_status')
        || 1000 !== (int) $query->get('posts_per_page')
        || 'ID' !== $query->get('orderby')
        || 'ASC' !== strtoupper((string) $query->get('order'))
        || false !== $query->get('cache_results')) {
        return;
    }

    $query->set('ignore_sticky_posts', true);
}

add_action(
    'pre_get_posts',
    'guard_wp_optimize_preload_query',
    PHP_INT_MAX
);

The guard is deliberately narrow. It does not disable sticky posts globally and does not affect normal home-page behavior. After installing it, the existing runaway workers must be terminated because code already executing will not restart itself with the new query arguments.

Verification after the fix

  • The deep-offset reproduction returned post_count=0.
  • A normal home query still returned the sticky post first.
  • The complete WP-Optimize URL enumeration returned 297 URLs in 0.194 seconds.
  • No deep-offset query remained in the MariaDB process list.
  • MariaDB dropped from more than two CPU cores to low single-digit CPU use immediately after the runaway workers stopped.
  • The one-minute load average dropped from approximately 3.5-4.0 to 0.24 on the four-CPU host.
  • The next automated health check passed without sending another alert.
  • The public site and its external availability monitor remained healthy.

Upstream status

As of September 3, 2026, I could not find a public WP-Optimize support report documenting this exact sticky-post preloader loop. The official 4.6.1 code and the current WordPress.org trunk source still contain the post iterator without ignore_sticky_posts. This article provides a reproducible case and the minimal correction needed upstream.

References

This is a plugin iterator bug, not a database-corruption event and not a reason to raise the server’s health threshold. The correct response is to make the iterator terminate.