From 9c9776cd2d1ffbee384023b6af7f077c9fd7e4ad Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Thu, 25 Jun 2026 10:18:06 -0700 Subject: [PATCH 1/2] Comments: Add wp_update_comment_counts() to recalculate stored counts. 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. --- src/wp-includes/comment.php | 49 ++++++ .../tests/comment/wpUpdateCommentCounts.php | 143 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 tests/phpunit/tests/comment/wpUpdateCommentCounts.php diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index acf634e2bab02..6819de6402427 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2926,6 +2926,55 @@ function wp_update_comment_count_now( $post_id ) { return true; } +/** + * Recalculates the stored comment count for one or more posts. + * + * Each post's `comment_count` is recomputed with wp_update_comment_count_now(), + * so the result honors the {@see 'default_excluded_comment_types'} filter. + * + * This is the recount counterpart to wp_update_comment_count(): the stored count + * is only refreshed for a post when its 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). A plugin that + * changes that set should call this once, typically from its activation routine, + * the same way rewrite rules are flushed with flush_rewrite_rules(). + * + * Recalculating every post is proportional to the number of posts that have + * comments and can be expensive on large sites. Pass a specific list of post IDs + * to limit the work, or run it from a maintenance context such as WP-CLI. + * + * @since 7.1.0 + * + * @global wpdb $wpdb WordPress database abstraction object. + * + * @param int[]|int|null $post_ids Optional. Post ID or array of post IDs to recalculate. + * Default null, which recalculates every post that has + * at least one comment. + * @return int Number of posts whose comment count was recalculated. + */ +function wp_update_comment_counts( $post_ids = null ) { + global $wpdb; + + if ( null === $post_ids ) { + $post_ids = $wpdb->get_col( "SELECT DISTINCT comment_post_ID FROM $wpdb->comments" ); + } + + $post_ids = array_unique( array_filter( array_map( 'absint', (array) $post_ids ) ) ); + + if ( empty( $post_ids ) ) { + return 0; + } + + $recalculated = 0; + foreach ( $post_ids as $post_id ) { + if ( wp_update_comment_count_now( $post_id ) ) { + ++$recalculated; + } + } + + return $recalculated; +} + // // Ping and trackback functions. // diff --git a/tests/phpunit/tests/comment/wpUpdateCommentCounts.php b/tests/phpunit/tests/comment/wpUpdateCommentCounts.php new file mode 100644 index 0000000000000..6ca08be44125f --- /dev/null +++ b/tests/phpunit/tests/comment/wpUpdateCommentCounts.php @@ -0,0 +1,143 @@ +update( $wpdb->posts, array( 'comment_count' => $count ), array( 'ID' => $post_id ) ); + clean_post_cache( $post_id ); + } + + /** + * @ticket 65537 + */ + public function test_returns_zero_when_there_is_nothing_to_recalculate() { + $this->assertSame( 0, wp_update_comment_counts( array() ) ); + $this->assertSame( 0, wp_update_comment_counts( 0 ) ); + } + + /** + * @ticket 65537 + */ + public function test_recalculates_only_the_given_posts() { + $post_a = self::factory()->post->create(); + $post_b = self::factory()->post->create(); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_a, + 'comment_approved' => 1, + ) + ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_b, + 'comment_approved' => 1, + ) + ); + + // Corrupt both stored counts, then recalculate only the first post. + $this->set_stored_count( $post_a, 99 ); + $this->set_stored_count( $post_b, 99 ); + + $recalculated = wp_update_comment_counts( $post_a ); + + $this->assertSame( 1, $recalculated ); + $this->assertSame( '1', get_comments_number( $post_a ) ); + $this->assertSame( '99', get_comments_number( $post_b ) ); + } + + /** + * @ticket 65537 + */ + public function test_deduplicates_repeated_post_ids() { + $post_id = self::factory()->post->create(); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => 1, + ) + ); + $this->set_stored_count( $post_id, 99 ); + + $recalculated = wp_update_comment_counts( array( $post_id, $post_id ) ); + + $this->assertSame( 1, $recalculated ); + $this->assertSame( '1', get_comments_number( $post_id ) ); + } + + /** + * @ticket 65537 + */ + public function test_null_recalculates_all_posts_with_comments() { + $post_a = self::factory()->post->create(); + $post_b = self::factory()->post->create(); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_a, + 'comment_approved' => 1, + ) + ); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_b, + 'comment_approved' => 1, + ) + ); + $this->set_stored_count( $post_a, 99 ); + $this->set_stored_count( $post_b, 99 ); + + wp_update_comment_counts(); + + $this->assertSame( '1', get_comments_number( $post_a ) ); + $this->assertSame( '1', get_comments_number( $post_b ) ); + } + + /** + * The headline scenario: a type that joins the excluded set after comments + * already exist must not keep inflating a previously stored count. + * + * @ticket 65537 + */ + public function test_recalculation_drops_a_newly_excluded_type() { + $post_id = self::factory()->post->create(); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_type' => 'review', + 'comment_approved' => 1, + ) + ); + + // Baseline: 'review' is counted, so the stored count is 1. + wp_update_comment_count_now( $post_id ); + $this->assertSame( '1', get_comments_number( $post_id ) ); + + // A plugin now excludes 'review'. The stored count stays stale until a recount. + $filter = static function ( $types ) { + $types[] = 'review'; + return $types; + }; + add_filter( 'default_excluded_comment_types', $filter ); + + $this->assertSame( '1', get_comments_number( $post_id ) ); + + $recalculated = wp_update_comment_counts( $post_id ); + + remove_filter( 'default_excluded_comment_types', $filter ); + + $this->assertSame( 1, $recalculated ); + $this->assertSame( '0', get_comments_number( $post_id ) ); + } +} From 5f41b0896121636c2e12a59f86bc1ee044291dab Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 11 Jul 2026 11:01:46 -0700 Subject: [PATCH 2/2] Comments: Harden wp_update_comment_counts() for production-scale use. - 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. --- src/wp-includes/comment.php | 78 +++++++++++++++---- .../tests/comment/wpUpdateCommentCounts.php | 67 +++++++++++++++- 2 files changed, 129 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index bcc5491ac2800..d18da94b70883 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2997,16 +2997,22 @@ function wp_update_comment_count_now( $post_id ) { * Each post's `comment_count` is recomputed with wp_update_comment_count_now(), * so the result honors the {@see 'default_excluded_comment_types'} filter. * - * This is the recount counterpart to wp_update_comment_count(): the stored count + * This is the bulk counterpart to wp_update_comment_count(): the stored count * is only refreshed for a post when its 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). A plugin that * changes that set should call this once, typically from its activation routine, * the same way rewrite rules are flushed with flush_rewrite_rules(). * + * Cached comment query results are salted only by the comment `last_changed` + * key, so it is bumped here: on a persistent object cache, queries cached before + * the excluded set changed would otherwise keep serving the old results. + * * Recalculating every post is proportional to the number of posts that have - * comments and can be expensive on large sites. Pass a specific list of post IDs - * to limit the work, or run it from a maintenance context such as WP-CLI. + * comments and can be expensive on large sites. Each recalculation also fires + * the usual post-update hooks (`wp_update_comment_count`, `edit_post`), so cache + * purgers and search indexers run once per post. Pass a specific list of post + * IDs to limit the work, or run it from a maintenance context such as WP-CLI. * * @since 7.1.0 * @@ -3014,29 +3020,71 @@ function wp_update_comment_count_now( $post_id ) { * * @param int[]|int|null $post_ids Optional. Post ID or array of post IDs to recalculate. * Default null, which recalculates every post that has - * at least one comment. + * at least one comment or a nonzero stored count. * @return int Number of posts whose comment count was recalculated. */ function wp_update_comment_counts( $post_ids = null ) { global $wpdb; - if ( null === $post_ids ) { - $post_ids = $wpdb->get_col( "SELECT DISTINCT comment_post_ID FROM $wpdb->comments" ); - } + /* + * Invalidate cached comment query results: they may reflect the previous + * set of default-excluded comment types. + */ + wp_cache_set_last_changed( 'comment' ); - $post_ids = array_unique( array_filter( array_map( 'absint', (array) $post_ids ) ) ); + $recalculated = 0; - if ( empty( $post_ids ) ) { - return 0; - } + if ( null !== $post_ids ) { + $post_ids = array_map( 'intval', (array) $post_ids ); + $post_ids = array_unique( + array_filter( + $post_ids, + static function ( $post_id ) { + return $post_id > 0; + } + ) + ); - $recalculated = 0; - foreach ( $post_ids as $post_id ) { - if ( wp_update_comment_count_now( $post_id ) ) { - ++$recalculated; + foreach ( $post_ids as $post_id ) { + if ( wp_update_comment_count_now( $post_id ) ) { + ++$recalculated; + } } + + return $recalculated; } + /* + * Visit every post that has at least one comment row, plus every post with + * a nonzero stored count (whose comment rows may all have been deleted), in + * keyset batches so the full ID list is never materialized in memory. + */ + $batch_size = 1000; + $last_post_id = 0; + + do { + $batch = $wpdb->get_col( + $wpdb->prepare( + "( SELECT DISTINCT comment_post_ID AS post_id FROM {$wpdb->comments} WHERE comment_post_ID > %d ) + UNION + ( SELECT ID AS post_id FROM {$wpdb->posts} WHERE comment_count <> 0 AND ID > %d ) + ORDER BY post_id + LIMIT %d", + $last_post_id, + $last_post_id, + $batch_size + ) + ); + + foreach ( $batch as $post_id ) { + if ( wp_update_comment_count_now( (int) $post_id ) ) { + ++$recalculated; + } + } + + $last_post_id = $batch ? (int) end( $batch ) : 0; + } while ( count( $batch ) === $batch_size ); + return $recalculated; } diff --git a/tests/phpunit/tests/comment/wpUpdateCommentCounts.php b/tests/phpunit/tests/comment/wpUpdateCommentCounts.php index 6ca08be44125f..1aeac19620b14 100644 --- a/tests/phpunit/tests/comment/wpUpdateCommentCounts.php +++ b/tests/phpunit/tests/comment/wpUpdateCommentCounts.php @@ -98,12 +98,77 @@ public function test_null_recalculates_all_posts_with_comments() { $this->set_stored_count( $post_a, 99 ); $this->set_stored_count( $post_b, 99 ); - wp_update_comment_counts(); + $recalculated = wp_update_comment_counts(); + $this->assertSame( 2, $recalculated ); $this->assertSame( '1', get_comments_number( $post_a ) ); $this->assertSame( '1', get_comments_number( $post_b ) ); } + /** + * Nonexistent, zero, and negative post IDs are skipped, not recounted. + * + * @ticket 65537 + */ + public function test_invalid_post_ids_are_skipped() { + $post_id = self::factory()->post->create(); + self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_approved' => 1, + ) + ); + $this->set_stored_count( $post_id, 99 ); + + // A negative ID must not be silently coerced into a valid one. + $this->assertSame( 0, wp_update_comment_counts( array( -$post_id, 0, PHP_INT_MAX ) ) ); + $this->assertSame( '99', get_comments_number( $post_id ), 'The negated ID should not recount the positive post.' ); + } + + /** + * An explicit ID for a post with no remaining comment rows forces a stale + * stored count back to zero. + * + * @ticket 65537 + */ + public function test_explicit_id_resets_stale_count_on_commentless_post() { + $post_id = self::factory()->post->create(); + $this->set_stored_count( $post_id, 5 ); + + $this->assertSame( 1, wp_update_comment_counts( $post_id ) ); + $this->assertSame( '0', get_comments_number( $post_id ) ); + } + + /** + * The null path also visits posts with a nonzero stored count but no + * remaining comment rows, which the comments table alone cannot reveal. + * + * @ticket 65537 + */ + public function test_null_path_resets_stale_count_on_commentless_post() { + $post_id = self::factory()->post->create(); + $this->set_stored_count( $post_id, 5 ); + + $recalculated = wp_update_comment_counts(); + + $this->assertSame( 1, $recalculated ); + $this->assertSame( '0', get_comments_number( $post_id ) ); + } + + /** + * Recounting bumps the comment last_changed key so cached query results + * from before the excluded set changed are invalidated. + * + * @ticket 65537 + */ + public function test_recount_invalidates_comment_query_cache() { + $before = wp_cache_get_last_changed( 'comment' ); + + wp_update_comment_counts( array() ); + + $this->assertNotSame( $before, wp_cache_get_last_changed( 'comment' ) ); + } + /** * The headline scenario: a type that joins the excluded set after comments * already exist must not keep inflating a previously stored count.