Skip to content

Apply Query Loop post exclusions in PHP instead of SQL - #31

Draft
roborourke wants to merge 1 commit into
mainfrom
claude/wp-cache-hit-ratio-c3u4fy
Draft

Apply Query Loop post exclusions in PHP instead of SQL#31
roborourke wants to merge 1 commit into
mainfrom
claude/wp-cache-hit-ratio-c3u4fy

Conversation

@roborourke

Copy link
Copy Markdown
Collaborator

The problem

WP_Query builds its post-queries cache key from md5( serialize( $args ) . $sql ) — the query vars and the generated SQL. Excluding posts with post__not_in puts the excluded IDs into the SQL, so a "related posts" loop that excludes the post being viewed gets a private cache entry on every URL. A site with 50,000 posts ends up with 50,000 cache entries for what is really one question, every one of them cold on first ask.

The approach

To show 5 posts excluding the current one, fetch 6 posts with no exclusion at all, drop the current post in PHP, render the first 5. The query — and the cache key — is then identical on every URL, and one entry serves the whole site.

This only works because of where core writes to the cache. In WP_Query::get_posts() the object cache write happens before posts_results and the_posts (WP 6.9: ~line 3455 vs ~line 3633; same relative order since at least 6.5). So the unfiltered superset is what gets cached, and anything removed in the_posts is removed per request. Filtering there is free in cache terms.

Over-fetching by exactly count( $exclude ) guarantees a full page: at most one fetched row can be dropped per excluded ID, so at least per_page survive. No top-up query is ever needed, and the fetch size depends on how many IDs are excluded, not which — so it stays stable across URLs.

Scoped to non-inherited loops, as suggested — those build their own WP_Query through query_loop_block_query_vars. Inherited loops run against the main query, whose key is per-URL regardless.

Changes

Deferred exclusions (inc/deferred-exclusions.php). Collects the IDs a loop wants to exclude, plans an over-fetch, drops them on the_posts at priority 9 — before the plugin's post tracking at 10, so only posts that really render are recorded. Fed by three sources: core's own query.excludeCurrent attribute (which core implements as post__not_in[] = get_the_ID(), so this needs no configuration to help), the plugin's Exclude already displayed posts setting, and a new hm_query_loop_deferred_exclusions filter.

Post templates share one query. Each core/post-template used to get a narrowed query of its own — smaller posts_per_page, preceding templates' IDs in post__not_in. That was N queries and N cache entries for one result set, and editing template 2's "posts per template" invalidated template 1's entry. They now all issue the loop's own unmodified query and take their own window out of the results in PHP.

The plugin no longer leaks state into the cache key. generate_cache_key() strips exactly seven query vars and serialises everything else — there is no allow-list, so any custom var is hashed in. The plugin was passing query_id, which is derived from the post ID and so gave every loop a private key on every URL, and hm_query_loop_collect_ids. Both now go in one var that is stripped on pre_get_posts, before the key is generated, and bound to the WP_Query instance instead. paged is now only set where offset is absent, since offset overrides it in the LIMIT clause.

found_posts is corrected per request, not through the found_posts filter — that filter only runs on a cache miss and its result is baked into the shared entry, so using it would write one URL's correction into every other URL's answer.

Exclusion sets are snapshotted per loop, so a loop's post templates and its pagination/total queries all exclude the same posts and share one entry.

Falling back

Exclusion returns to SQL when the fetch would exceed hm_query_loop_max_deferred_fetch (default 100 — deep pagination fetches per_page * page + n), when hm_query_loop_defer_exclusions is filtered false, or when the query cannot reach the_posts at all (fields => 'ids' and suppress_filters both skip it in core).

Costs

A few extra rows per query; a foreach over them on every request including cache hits; and because the array changes, core takes its _prime_post_caches() path rather than the update_post_caches() fast path. found_posts becomes a lower bound for loops that both paginate and exclude, since only the fetched window is visible.

Testing

npm run test:php — 27 assertions covering the planner: the exclude-current case producing identical SQL across URLs, exclusion on page 2, template windowing, windows and exclusions together, both fallback paths, and an assertion that no plugin state survives into query_vars. Runs on plain PHP with WordPress stubbed, so it needs no Docker; added as a CI job.

I could not run the Playwright suite here (no Docker in this environment), so the existing e2e coverage — multiple-post-templates, exclude-with-post-in, posts-per-page — is unverified against these changes and will get its first run in CI. I'll follow up on anything that fails.

Docs

docs/query-caching.md has the full reasoning, the core-internals findings it rests on, the trade-offs, and a ranked list of what is left — inherited queries, sharing result sets between sibling loops, quantising posts_per_page, no_found_rows. It also flags the thing that dominates everything else: any post meta write, on any post, invalidates every cached query on the site, so on a site with view counters or similar per-request meta writes, key cardinality is not the bottleneck. There is a snippet for measuring that first.


Generated by Claude Code

WP_Query builds its `post-queries` cache key from the query vars and the
generated SQL, so putting excluded IDs into `post__not_in` gives every URL a
cache entry of its own. A "related posts" loop that excludes the post being
viewed therefore never shares a cached result set with any other post, even
though every one of those queries asks the same question.

For non-inherited query loops, over-fetch by the number of exclusions and drop
the unwanted posts on `the_posts` instead. Core writes the result to the object
cache before `the_posts` runs, so the shareable superset is what gets cached and
the filtering costs nothing in cache terms. Fetching `per_page + count(exclude)`
rows guarantees a full page: at most one row can be dropped per excluded ID.

This covers the plugin's own "exclude already displayed posts" setting, core's
`excludeCurrent` block attribute, and anything added via the new
`hm_query_loop_deferred_exclusions` filter. It falls back to SQL exclusion past
`hm_query_loop_max_deferred_fetch`, when `hm_query_loop_defer_exclusions` is
disabled, or when the query cannot reach `the_posts`.

Alongside that:

- Post templates now share one query. Each used to get a narrowed query of its
  own, with the preceding templates' posts in `post__not_in`; they now all issue
  the loop's own unmodified query and window the results in PHP, so N templates
  cost one query and one cache entry.
- The plugin no longer leaks its own state into the cache key. Every custom
  query var is hashed into it, and `query_id` is derived from the post ID, so it
  was giving each loop a private key on every URL. Both it and the tracking flag
  are now stripped on `pre_get_posts`, before the key is generated.
- `paged` is only set where `offset` is absent, since `offset` overrides it in
  the LIMIT clause and it was otherwise just noise in the key.
- The exclusion set is snapshotted per loop, so a loop's post templates and its
  pagination query all exclude the same posts and share one entry.

Adds unit tests for the planner that need only PHP, and docs/query-caching.md
with the reasoning and what is still left to do.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab
@github-actions

Copy link
Copy Markdown

Playwright test results

passed  22 passed

Details

stats  22 tests across 7 suites
duration  1 minute, 17 seconds
commit  8d3afeb

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