Apply Query Loop post exclusions in PHP instead of SQL - #31
Draft
roborourke wants to merge 1 commit into
Draft
Conversation
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
Playwright test resultsDetails
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
WP_Querybuilds itspost-queriescache key frommd5( serialize( $args ) . $sql )— the query vars and the generated SQL. Excluding posts withpost__not_inputs 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 beforeposts_resultsandthe_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 inthe_postsis 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 leastper_pagesurvive. 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_Querythroughquery_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 onthe_postsat priority 9 — before the plugin's post tracking at 10, so only posts that really render are recorded. Fed by three sources: core's ownquery.excludeCurrentattribute (which core implements aspost__not_in[] = get_the_ID(), so this needs no configuration to help), the plugin's Exclude already displayed posts setting, and a newhm_query_loop_deferred_exclusionsfilter.Post templates share one query. Each
core/post-templateused to get a narrowed query of its own — smallerposts_per_page, preceding templates' IDs inpost__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 passingquery_id, which is derived from the post ID and so gave every loop a private key on every URL, andhm_query_loop_collect_ids. Both now go in one var that is stripped onpre_get_posts, before the key is generated, and bound to theWP_Queryinstance instead.pagedis now only set whereoffsetis absent, sinceoffsetoverrides it in the LIMIT clause.found_postsis corrected per request, not through thefound_postsfilter — 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 fetchesper_page * page + n), whenhm_query_loop_defer_exclusionsis filtered false, or when the query cannot reachthe_postsat all (fields => 'ids'andsuppress_filtersboth skip it in core).Costs
A few extra rows per query; a
foreachover them on every request including cache hits; and because the array changes, core takes its_prime_post_caches()path rather than theupdate_post_caches()fast path.found_postsbecomes 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 intoquery_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.mdhas 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, quantisingposts_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