Skip to content

Comments: Add wp_update_comment_counts() to reconcile stored counts - #58

Open
adamsilverstein wants to merge 7 commits into
feature/65537-excluded-comment-types-filterfrom
feature/65537-update-comment-counts
Open

Comments: Add wp_update_comment_counts() to reconcile stored counts#58
adamsilverstein wants to merge 7 commits into
feature/65537-excluded-comment-types-filterfrom
feature/65537-update-comment-counts

Conversation

@adamsilverstein

@adamsilverstein adamsilverstein commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Follow-up to the #12310 counter fix, addressing the second half of pfefferle's review on Trac #35214 comment:52: keeping a post's stored comment_count consistent when the set of excluded comment types changes.

The gap

wp_update_comment_count_now() (made filter-aware in WordPress#12310) only refreshes a post's stored comment_count when that post's comments change. So a count written before a type joined the excluded set stays stale until the post sees activity again. Example: a site runs with review comments counting normally, then installs a plugin that excludes review via default_excluded_comment_types - existing posts keep their inflated count.

Why not auto-recount on registration

register_comment_type() runs on every request during init; it is not a persisted state transition, and the exclusion set is driven by the default_excluded_comment_types filter, not by register/unregister. There is no clean event to hook. Investigated alternatives and rejected them:

  • Recount on unregister_comment_type - wrong event (exclusion changes come from the filter, not unregister) and per-request.
  • "Dirty set" transient + lazy recount on read - would UPDATE during a GET (breaks read replicas / page caches; get_comments_number() is deliberately read-only) and relies on diffing a per-request filter output on the hot path.

The closest core analog confirms the right pattern: taxonomy term counts are recalculated only on data events, never on taxonomy registration. Rewrite rules use the same model - core exposes flush_rewrite_rules() and documents "call it on activation" rather than auto-detecting.

This PR

wp_update_comment_counts( $post_ids = null ) - a bulk recount helper that recomputes one or more posts' counts through the now filter-aware wp_update_comment_count_now(), so it honors the same exclusion set by construction (no new SQL). A plugin that changes the excluded set calls it once, typically from its activation hook; it also gives a future WP-CLI/admin maintenance tool a single correct entry point.

  • null (default) recalculates every post that has at least one comment.
  • An int or array of post IDs limits the work (deduped, invalid IDs dropped).
  • Returns the number of posts recalculated.

Naming follows the core wp_update_*_counts() bulk family (wp_update_user_counts(), wp_update_network_counts()); singular wp_update_comment_count() already exists for a single post.

Tests

New tests/phpunit/tests/comment/wpUpdateCommentCounts.php: empty input returns 0; targeted IDs only touch those posts; duplicate IDs are deduped; null recalculates all posts with comments; and the headline case - a newly-excluded review type drops a previously stored count to 0.

  • 562 --group comment tests pass (557 + 5 new).
  • PHPCS clean (0 errors), PHPStan clean.

Review updates

  • wp_update_comment_counts() now bumps the comment last_changed cache key. Comment query caches are salted only by that key, so the activation-time register-filter-then-recount flow is now fully self-consistent on persistent object caches.
  • The null path additionally visits posts with a nonzero stored count but no remaining comment rows - a SELECT DISTINCT on the comments table cannot see those - and iterates in keyset batches of 1000 instead of materializing every post ID.
  • Negative IDs are skipped instead of being absint()-coerced into valid ones.
  • Docs now name the per-post side effects: the edit_post hooks fire per recount, so cache purgers and indexers run once per post.
  • New tests cover the null-path return value, invalid IDs, stale-count resets on commentless posts via both paths, and the last_changed bump.

Stacked on WordPress#12310 (feature/65537-excluded-comment-types-filter); retarget to trunk once that lands.

Following a second review pass over the stack:

  • Each arm of the batch query is bounded. The keyset query unions "posts with comments" and "posts with a nonzero stored count", and only the outer query carried a LIMIT. MySQL cannot push an outer ORDER BY ... LIMIT into parenthesized union arms, so on a large site each iteration materialized both full result sets before trimming to a batch. Each arm now carries its own ordered LIMIT, which is semantics-preserving: the first N rows of the union of two ascending sets are always among the first N of each.
  • A no-op recount no longer flushes every cached comment query. The last_changed bump ran before the early return, so wp_update_comment_counts( array() ) invalidated every cached comment query on the site while recalculating nothing. It now happens only when there is something to recount.
  • The batch size is filterable via wp_update_comment_counts_batch_size, floored at 1, so a site with an unusual comment distribution can tune it and the tests can exercise the batch boundary without creating thousands of posts.

Testing

$ phpunit --group comment
Tests: 668, Assertions: 1618, Skipped: 1.

(The skipped test is WordPress#12310's registry-integration case, which guards on get_comment_types() existing. This branch is stacked on WordPress#12310 but not on the registration API in #12311, so it correctly skips here and runs once the two meet.)

PHPCS reports no new warnings on the changed files and PHPStan is clean.

AI Use

Code and description both written with 🤖 Claude Code. I will review and test.

wp_update_comment_count_now() only refreshes a post's stored comment_count
when that post's comments change, so an existing count can become stale
after the set of excluded comment types changes (for example when a plugin
registers a type that opts out of default listings via
default_excluded_comment_types). Registration runs on every request and is
not a state transition, so there is no safe automatic trigger; the
established core pattern for this is an explicit recount, like
flush_rewrite_rules() for rewrite rules.

Add a bulk recount helper that recomputes one or more posts' counts through
wp_update_comment_count_now(), so it honors the same exclusion filter. A
plugin that changes the excluded set calls it once, typically on activation.

See #35214, #65537.
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7cbf8a18-5ec9-492d-9d2d-001a2368ec88

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/65537-update-comment-counts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

- Bump the comment 'last_changed' cache key: comment query results are
  salted only by that key, so on a persistent object cache the
  activation-time flow (register the exclusion filter, then recount)
  would otherwise keep serving results cached under the previous
  excluded set.
- Include posts with a nonzero stored count but no remaining comment
  rows in the null path: SELECT DISTINCT on the comments table cannot
  see a post whose rows were all deleted while its stored count is
  stale.
- Iterate the null path in keyset batches of 1000 instead of
  materializing every post ID in memory.
- Skip negative IDs instead of silently recounting their absolute
  value, and document the per-post side effects (edit_post hooks fire
  for cache purgers and indexers) alongside the cache-invalidation
  behavior.
- Tests: null-path return value, invalid/negative/nonexistent IDs,
  stale-count reset for commentless posts on both paths, and the
  last_changed bump.
The keyset loop was correct but its query was not batched in any useful sense.
MySQL cannot push an outer ORDER BY ... LIMIT into a parenthesized UNION arm, so
every iteration materialized all remaining rows of both arms into a temp table
before taking its thousand - and since comment_count is unindexed, the posts arm
scanned the whole remaining table each time. A recount of N commented posts cost
roughly N squared over the batch size, which defeats the batching that is the
whole point of the null path.

Give each arm its own ORDER BY and LIMIT. Keyset semantics are unchanged: the
first N rows of the union of two ascending sets are always among the first N of
each, so the batch is the same batch, read with a bounded walk of each index.

The batch size becomes filterable along the way, which also makes it testable
across a boundary - including a post that has both comments and a stale count,
so it turns up in both arms.

See #65537.
The comment last_changed key was bumped first thing, before the post IDs were
even validated, so wp_update_comment_counts( array() ) or a list of nothing but
invalid IDs invalidated every cached comment query on the site while recounting
nothing at all. Bump it once there is at least one post to visit.

The existing cache test relied on that no-op bump, so it now recounts a real
post, and the no-op cases get their own test asserting the key is left alone.

See #65537.
The docblock recommended calling this from a plugin activation routine while
also warning it can be expensive, which is contradictory advice: activation runs
in a normal web request, and a full recount on a large site will hit
max_execution_time partway through. Point at wp_schedule_single_event() or WP-CLI
for that case, and say plainly that stopping partway is safe - posts visited
before the stop are correct and the operation is idempotent.

Three more things the function does that a caller could only learn by reading it:
counts are written immediately rather than joining the wp_defer_comment_counting()
queue, post IDs that do not exist are skipped and left out of the return value,
and there is no capability check, so anything exposing this to a request has to
add its own.

See #65537.
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.

1 participant