Apply Query Loop post exclusions in PHP instead of SQL - #31
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
Failed testschromium › exclude-with-post-in.spec.js › Exclude Displayed Posts with post__in › should exclude displayed posts even when post__in is set via Advanced Query Loop |
I reviewed and benchmarked this against a client project (28k posts, homepage + article pages). Short version: the approach works as intended, but I couldn't see a benefit with our content. The reason seems to be outside this PR. You mentioned this was untested, so I focused mainly on measuring it. The test suites are green on the current head ( The approach looks goodMoving everything into I instrumented Why it doesn't change anything for usI got zero difference on this project: 74 SQL / 45 cached, 37% hit rate, the same with and without the PR across 12 cold article loads. The reason is not this PR. Our Because I tested removing it at priority 11, after query-filter's transpose, and it took us from 68 → 60 queries across 12 page loads, and 41% → 49% hit rate. The output was byte-identical and filtering was still working correctly. This fix only works together with this PR though. Without this PR, Bucketing the fetch - tried it, but I don't recommend it
I tried rounding the fetch up to a whole page and measured it, but got no difference at all - 76 SQL / 68 cached / 47% hit, the same with and without it, tested twice. The variance I initially saw ( Based on that, I don't think the extra complexity is worth it. It can read up to an extra page of rows and also breaks three tests that expect the exact fetch size ( I'm parking this instead of pushing it. It could be useful if we have loops that are identical except for their exclusion count - same post type, same taxonomy, no Bucketing patch (not recommended, for reference)--- a/inc/deferred-exclusions.php
+++ b/inc/deferred-exclusions.php
@@ -262,12 +262,35 @@ function can_filter_results( array $query ): bool {
return ! in_array( $query['fields'] ?? '', [ 'ids', 'id=>parent' ], true );
}
+/**
+ * Granularity the over-fetch is rounded up to.
+ *
+ * A page of results is the natural unit: it is already the size the loop thinks
+ * in, and it keeps the worst-case waste to one page of rows.
+ *
+ * @param int $loop_per_page Posts the loop renders per page.
+ * @return int Bucket size, or 0 to round not at all.
+ */
+function bucket_size( int $loop_per_page ): int {
+ /**
+ * Filters the granularity the deferred-exclusion over-fetch rounds up to.
+ *
+ * Larger buckets share cache entries more widely and read more rows to do it.
+ * Return 0 to fetch exactly what the exclusions require.
+ *
+ * @param int $bucket Defaults to the loop's page size.
+ * @param int $loop_per_page Posts the loop renders per page.
+ */
+ return max( 0, (int) apply_filters( 'hm_query_loop_fetch_bucket', $loop_per_page, $loop_per_page ) );
+}
+
/**
* Work out what to fetch so the loop can be assembled in PHP afterwards.
*
* Over-fetching by `count( $exclude )` guarantees a full page after filtering:
* at most one fetched post can be dropped per excluded ID, so at least as many
- * survive as the loop asked for.
+ * survive as the loop asked for. The fetch is then rounded up — see
+ * bucket_size() — which only ever adds to that margin.
*
* @param array $query Query vars for the loop.
* @param int[] $exclude Post IDs to exclude.
@@ -317,6 +340,18 @@ function build_plan( array $query, array $exclude, int $page, array $context ):
$base_offset = $plan['fetch_offset'] - ( $loop_per_page * ( $page - 1 ) );
$fetch = ( $loop_per_page * $page ) + count( $exclude );
+ // The fetch size lands in the cache key, so letting it track the exclusion
+ // count exactly puts the variance straight back where it was taken from —
+ // a loop excluding five posts and the same loop excluding six issue
+ // different queries. Rounding up to a whole number of pages collapses that
+ // into one query per page of results. The extra rows are trimmed in PHP,
+ // so only the size of the fetch changes, never what the loop renders.
+ $bucket = bucket_size( $loop_per_page );
+
+ if ( $bucket > 0 ) {
+ $fetch = (int) ( ceil( $fetch / $bucket ) * $bucket );
+ }
+
// A negative base offset means the offset was not built by core's
// formula, and re-slicing would silently move the window. Past
// get_max_fetch() the over-fetch costs more than the shared cache entry |
Three conflicts, all from the sticky-posts feature landing in the same places this branch touches: - `hm-query-loop.php` require/init blocks: both modules load, sticky first so its `posts_orderby` filter is registered before the exclusion planner runs. - `modify_query_from_block_attrs()`: kept the sticky-posts stash and dropped main's `excludeDisplayedForCurrentLoop` block, which this branch replaced — post templates no longer exclude each other's posts, they window one shared result set. - README: both features kept, sticky renumbered to 8. The two compose as they stand. Sticky ordering is applied in SQL, so the over-fetched set arrives already ordered and the PHP filtering and windowing preserve that order. `StickyPosts\QUERY_VAR` does reach `WP_Query` — unlike this plugin's own bookkeeping, which is stripped on `pre_get_posts` — but it only repeats IDs the ORDER BY already puts in the SQL, so it adds no cache-key fragmentation of its own. Also documents `stickyPosts` in the CLAUDE.md context block, which the README already listed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab
Benchmarked the branch against b9f3925 on WP 6.9 / PHP 8.4 / MariaDB with 601 posts, rendering block fixtures across many URLs with the object cache persisting between them. The headline result is not the one the docs led with. A single query loop with no plugin settings at all — nothing for the over-fetching to do — still goes from 306 database queries to 108 across 100 URLs, because the gain there is entirely from no longer putting `query_id` in the query vars. Three distinct queries were being re-executed 201 times purely because their cache keys differed by post ID. Since `query_id` was set for every post-template query, every query loop the plugin touched had a private cache entry on every URL it rendered on. On a 12-loop page across 40 URLs: 1263 database queries to 72, and 50.4 to 34.5 ms per URL, with output identical post for post. A cold render against an empty cache — the worst case for over-fetching — is within noise at 74.9 vs 73.8 ms, and still drops 87 queries to 66. The admin editor is unaffected in every measurement, which is what the code predicts: the built editor bundle is byte-identical between the two builds and `query_loop_block_query_vars` does not fire in admin. Also corrects a claim these docs made: `excludeCurrent` is not in WordPress 6.9, only in core trunk. On 6.9 and earlier core ignores the attribute, so this plugin is not "taking it over" there — it is what makes the setting do anything at all. That is a behaviour change on those versions and is now called out as one; the benchmark shows it changing the rendered posts on 5 of 25 URLs, exactly those where the current post fell inside the window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab
Same rig, second install on 7.1. The results hold: a plain query loop with no plugin settings goes from 306 database queries to 108 across 100 URLs, and the 12-loop page from 1261 to 70 across 40 URLs, with render time down roughly a third in both. The cold-render worst case stays within noise. Admin is unaffected on both versions. 7.1 also settles the `excludeCurrent` question. Core gained the attribute in 7.1 — not 6.9, and not 7.0, both of which ignore it — so on 7.1 this branch is output-identical on every fixture including the related-posts one, where on 6.9 it differs on the URLs whose current post falls inside the window. The version boundary in the docs was wrong in the other direction too and is now stated as measured rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab
BenchmarksWordPress 6.9 and 7.1, PHP 8.4, MariaDB same host, Twenty Twenty-Five, 601 posts. Each "URL" is a full block render with the object cache persisting between renders, as a persistent object cache does between requests. Compared against A plain query loop, no plugin settings, 100 URLs
This loop has no exclusion settings, so none of the over-fetching does anything. The whole gain is that 12-loop page (two split across multiple post templates), 40 URLs
Cost: one cold render, empty cache (worst case for over-fetching)
Within noise over 15–20 interleaved runs per build. AdminMedians of 15 requests, two rounds, shown as a range across rounds.
Every gap is smaller than the spread between two rounds of the same build. Expected: the built editor bundle is byte-identical and Rendered output, compared post by post across 25 URLs
The one divergence is a core version boundary, measured rather than assumed: Full method and numbers in Generated by Claude Code |
An earlier edit spliced the corrected version boundary into the middle of a sentence and left it reading badly. Same facts, stated once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab
The 2 Playwright failures are pre-existing, not from this PRBoth failures are in Control experiment: I re-ran
Unchanged code, same failure. So this is drift in the test environment, not a regression here. Why it's not this branch, independently of that: this PR ships no editor JavaScript — the built bundle is byte-identical to main's — and its PHP hooks either don't fire in admin ( Likely cause: I've not tried to fix it inside this PR, since it is unrelated to the change and would muddy the diff. Happy to take it as a separate piece of work — pinning core and AQL to fixed versions would stop the class of failure, not just this instance. Generated by Claude Code |
The problem
WP_Querybuilds itspost-queriescache key frommd5( serialize( $args ) . $sql )— the query vars and the generated SQL. Two things onmainmake that key vary per URL:query_idinto the query vars for everycore/post-templatequery.query_idis derived from the post ID, so every query loop the plugin touches gets a private cache entry on every URL it renders on — whether or not any of the plugin's features are enabled.post__not_inputs the excluded IDs into the SQL, so a loop excluding the post being viewed can never share a cached result.Benchmarking says (1) is by far the bigger effect.
What it measures out at
Against
b9f3925, on WP 6.9 / PHP 8.4 / MariaDB, 601 posts, object cache persisting between renders (what a persistent object cache does between requests):A single query loop with no plugin settings at all, across 100 URLs:
SELECTs executedNothing in the over-fetching machinery is doing anything here. Three distinct queries were being re-executed 201 times purely because their cache keys differed by post ID.
A 12-loop magazine page (two loops split across multiple post templates), across 40 URLs:
SELECTs executedCost, where no cache benefit is available — one cold render against an empty cache, the worst case for over-fetching:
Within noise over 20 interleaved runs each.
The admin editor is unaffected. Medians of 15 requests, two rounds: block editor 218–235 ms before / 215–221 ms after; posts list 61–65 / 62–63; site editor 117–125 / 121–125; REST
wp/v2/posts28–30 / 28. Every gap is smaller than the spread between two rounds of the same build. That is what the code predicts — the built editor bundle is byte-identical between the two, andquery_loop_block_query_varsdoes not fire in admin.Method and full numbers are in
docs/query-caching.md.The approach
To show 5 posts excluding the current one, fetch 6 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.
This 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 ~3633). The unfiltered superset is what gets cached; anything removed inthe_postsis removed per request.Over-fetching by exactly
count( $exclude )guarantees a full page: at most one fetched row can be dropped per excluded ID. The fetch size depends on how many IDs are excluded, not which, so it stays stable across URLs.Scoped to non-inherited loops. Inherited loops run against the main query, whose key is per-URL regardless.
Changes
Nothing the plugin tracks reaches the cache key.
generate_cache_key()strips exactly seven query vars and serialises everything else — there is no allow-list.query_idandhm_query_loop_collect_idsnow travel in one var that is stripped onpre_get_posts, before the key is generated, and bound to theWP_Queryinstance instead.pagedis only set whereoffsetis absent, sinceoffsetoverrides it in the LIMIT clause.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 post tracking at 10, so only posts that really render are recorded.Post templates share one query. Each used to get a narrowed query of its own — smaller
posts_per_page, preceding templates' IDs inpost__not_in. They now all issue the loop's own unmodified query and window the results in PHP.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.A behaviour change worth flagging
excludeCurrentis not in WordPress 6.9 — only in core trunk. On 6.9 and earlier core ignores the attribute entirely, so this plugin does not "take it over" there; it is what makes the setting do anything. A loop whose block attributes carryexcludeCurrentstarts excluding the current post where the attribute previously did nothing. The benchmark shows this changing the rendered posts on 5 of 25 URLs — exactly those where the current post fell inside the window. Intended behaviour for the setting, but a behaviour change, and the docs now say so.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_posts(fields => 'ids'andsuppress_filtersboth skip it in core).Testing
npm run test:php— 27 assertions on the planner, running on plain PHP with WordPress stubbed, added as a CI job. Playwright: 22/22 green. PHPCS (added tomainsince this branch opened) passes.Merged
mainin: the sticky-posts feature landed in the same places. Both compose — sticky ordering is applied in SQL, so the over-fetched set arrives already ordered and the PHP filtering and windowing preserve it.StickyPosts\QUERY_VARdoes reachWP_Query, but it only repeats IDs the ORDER BY already puts in the SQL, so it adds no fragmentation of its own.Still worth knowing
Any post meta write, on any post, bumps
postslast_changedand invalidates every cached query site-wide. On a site with view counters or similar per-request meta writes that swamps all of this.docs/query-caching.mdhas a snippet to measure it before investing further, plus a ranked list of what is left — inherited queries, sharing result sets between sibling loops, quantisingposts_per_page.🤖 Generated with Claude Code
https://claude.ai/code/session_01Fbx2jG1eYxRD4UzUB7gxab